diff --git a/.dockerignore b/.dockerignore index a3096e7d40..e182865ae0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ open-api/typescript-sdk/node_modules/ server/coverage/ server/node_modules/ server/upload/ +server/src/queries server/dist/ server/www/ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..c7519a4684 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://buy.immich.app'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 12ffc89ea2..346c6e60f2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -83,7 +83,6 @@ body: 2. 3. ... - render: bash validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ae0368861a..0b0cfbafd9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,14 @@ blank_issues_enabled: false contact_links: - - name: I have a question or need support + - name: ✋ I have a question or need support url: https://discord.immich.app about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support. - - name: Feature Request + - name: 📷 My photo or video has a date, time, or timezone problem + url: https://github.com/immich-app/immich/discussions/12650 + about: Upload a sample file to this discussion and we will take a look + - name: 🌟 Feature request url: https://github.com/immich-app/immich/discussions/new?category=feature-request about: Please use our GitHub Discussion for making feature requests. - - name: I'm unsure where to go + - name: 🫣 I'm unsure where to go url: https://discord.immich.app about: If you are unsure where to go, then joining our Discord is recommended; Just ask! diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2c7d170839..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/labeler.yml b/.github/labeler.yml index a0eec41346..c0c52f1d7e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -33,3 +33,6 @@ documentation: - changed-files: - any-glob-to-any-file: - machine-learning/app/** + +changelog:translation: + - head-branch: ['^chore/translations$'] diff --git a/.github/release.yml b/.github/release.yml index 03483f9197..c549ead475 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,41 +1,33 @@ changelog: categories: - - title: ⚠️ Breaking Changes + - title: 🚨 Breaking Changes labels: - - breaking-change + - changelog:breaking-change - - title: 🗄️ Server + - title: 🫥 Deprecated Changes labels: - - 🗄️server + - changelog:deprecated - - title: 📱 Mobile + - title: 🔒 Security labels: - - 📱mobile + - changelog:security - - title: 🖥️ Web + - title: 🚀 Features labels: - - 🖥️web + - changelog:feature - - title: 🧠 Machine Learning + - title: 🌟 Enhancements labels: - - 🧠machine-learning + - changelog:enhancement - - title: ⚡ CLI + - title: 🐛 Bug fixes labels: - - cli + - changelog:bugfix - - title: 📓 Documentation + - title: 📚 Documentation labels: - - documentation + - changelog:documentation - - title: 🔨 Maintenance + - title: 🌐 Translations labels: - - deployment - - dependencies - - renovate - - maintenance - - tech-debt - - - title: Other changes - labels: - - "*" + - changelog:translation diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 948f67e3d4..c12b6e607a 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -16,10 +16,28 @@ concurrency: cancel-in-progress: true jobs: + pre-job: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + mobile: + - 'mobile/**' + - 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" + build-sign-android: name: Build and sign Android + needs: pre-job # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }} + if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }} runs-on: macos-14 steps: diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 691bfdcce8..7052fa6ef9 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -22,7 +22,7 @@ permissions: jobs: publish: - name: Publish + name: CLI Publish runs-on: ubuntu-latest defaults: run: @@ -59,7 +59,7 @@ jobs: uses: docker/setup-qemu-action@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 + uses: docker/setup-buildx-action@v3.7.1 - 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.5.0 + uses: docker/build-push-action@v6.9.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker-cleanup.yml b/.github/workflows/docker-cleanup.yml index bd0ec91d14..29b518e0a5 100644 --- a/.github/workflows/docker-cleanup.yml +++ b/.github/workflows/docker-cleanup.yml @@ -22,7 +22,7 @@ concurrency: jobs: cleanup-images: name: Cleanup Stale Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -35,7 +35,7 @@ jobs: steps: - name: Clean temporary images if: "${{ env.TOKEN != '' }}" - uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0 + uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0 with: token: "${{ env.TOKEN }}" owner: "immich-app" @@ -48,7 +48,7 @@ jobs: cleanup-untagged-images: name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - cleanup-images strategy: @@ -64,7 +64,7 @@ jobs: steps: - name: Clean untagged images if: "${{ env.TOKEN != '' }}" - uses: stumpylog/image-cleaner-action/untagged@v0.8.0 + uses: stumpylog/image-cleaner-action/untagged@v0.9.0 with: token: "${{ env.TOKEN }}" owner: "immich-app" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0cf2668ec5..034fbe0008 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,47 +17,106 @@ permissions: packages: write jobs: - build_and_push: - name: Build and Push + pre-job: runs-on: ubuntu-latest + outputs: + should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + server: + - 'server/**' + - 'openapi/**' + - 'web/**' + - 'i18n/**' + machine-learning: + - 'machine-learning/**' + + - 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" + + retag_ml: + name: Re-Tag ML + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + strategy: + matrix: + suffix: ["", "-cuda", "-openvino", "-armnn"] + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Re-tag image + run: | + 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 + + retag_server: + name: Re-Tag Server + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + strategy: + matrix: + suffix: [""] + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Re-tag image + run: | + 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 + + + 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 + env: + image: immich-machine-learning + context: machine-learning + file: machine-learning/Dockerfile strategy: # Prevent a failure in one image from stopping the other builds fail-fast: false matrix: include: - - image: immich-machine-learning - context: machine-learning - file: machine-learning/Dockerfile - platforms: linux/amd64,linux/arm64 + - platforms: linux/amd64,linux/arm64 device: cpu - - image: immich-machine-learning - context: machine-learning - file: machine-learning/Dockerfile - platforms: linux/amd64 + - platforms: linux/amd64 device: cuda suffix: -cuda - - image: immich-machine-learning - context: machine-learning - file: machine-learning/Dockerfile - platforms: linux/amd64 + - platforms: linux/amd64 device: openvino suffix: -openvino - - image: immich-machine-learning - context: machine-learning - file: machine-learning/Dockerfile - platforms: linux/arm64 + - platforms: linux/arm64 device: armnn suffix: -armnn - - image: immich-server - context: . - file: server/Dockerfile - platforms: linux/amd64,linux/arm64 - device: cpu - steps: - name: Checkout uses: actions/checkout@v4 @@ -66,7 +125,7 @@ jobs: uses: docker/setup-qemu-action@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 + uses: docker/setup-buildx-action@v3.7.1 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -93,8 +152,8 @@ jobs: # Disable latest tag latest=false images: | - name=ghcr.io/${{ github.repository_owner }}/${{matrix.image}} - name=altran1502/${{matrix.image}},enable=${{ github.event_name == 'release' }} + 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 }} @@ -111,18 +170,18 @@ jobs: # Essentially just ignore the cache output (PR 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:${{ matrix.image }}" >> $GITHUB_OUTPUT + echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ env.image }}" >> $GITHUB_OUTPUT fi - name: Build and push image - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.9.0 with: - context: ${{ matrix.context }} - file: ${{ matrix.file }} + 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:${{matrix.image}} + 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 }} @@ -132,3 +191,120 @@ jobs: BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} + + + build_and_push_server: + name: Build and Push Server + runs-on: ubuntu-latest + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} + env: + image: immich-server + context: . + file: server/Dockerfile + strategy: + fail-fast: false + matrix: + include: + - platforms: linux/amd64,linux/arm64 + device: cpu + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.7.1 + + - 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 }} + + - 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 + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # Essentially just ignore the cache output (PR 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 + fi + + - name: Build and push image + uses: docker/build-push-action@v6.9.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 }} + build-args: | + DEVICE=${{ matrix.device }} + 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 }} + + success-check-server: + name: Docker Build & Push Server Success + needs: [build_and_push_server, retag_server] + 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) }}" + + success-check-ml: + name: Docker Build & Push ML Success + needs: [build_and_push_ml, retag_ml] + 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/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 32e9dc399a..efb84d510e 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -2,12 +2,8 @@ name: Docs build on: push: branches: [main] - paths: - - "docs/**" pull_request: branches: [main] - paths: - - "docs/**" release: types: [published] @@ -16,7 +12,27 @@ concurrency: cancel-in-progress: true jobs: + pre-job: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + docs: + - 'docs/**' + - 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" + build: + name: Docs Build + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run == 'true' }} runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 62f213eb2a..ab197fa459 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -7,13 +7,32 @@ on: jobs: checks: + name: Docs Deploy Checks runs-on: ubuntu-latest outputs: parameters: ${{ steps.parameters.outputs.result }} + artifact: ${{ steps.get-artifact.outputs.result }} steps: - - if: ${{ github.event.workflow_run.conclusion == 'failure' }} - run: echo 'The triggering workflow failed' && exit 1 - + - if: ${{ github.event.workflow_run.conclusion != 'success' }} + run: echo 'The triggering workflow did not succeed' && exit 1 + - name: Get artifact + id: get-artifact + uses: actions/github-script@v7 + with: + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "docs-build-output" + })[0]; + if (!matchArtifact) { + console.log("No artifact found with the name docs-build-output, build job was skipped") + return { found: false }; + } + return { found: true, id: matchArtifact.id }; - name: Determine deploy parameters id: parameters uses: actions/github-script@v7 @@ -73,9 +92,10 @@ jobs: return parameters; deploy: + name: Docs Deploy runs-on: ubuntu-latest needs: checks - if: ${{ fromJson(needs.checks.outputs.parameters).shouldDeploy }} + if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -98,18 +118,11 @@ jobs: uses: actions/github-script@v7 with: script: | - let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { - return artifact.name == "docs-build-output" - })[0]; + let artifact = ${{ needs.checks.outputs.artifact }}; let download = await github.rest.actions.downloadArtifact({ owner: context.repo.owner, repo: context.repo.repo, - artifact_id: matchArtifact.id, + artifact_id: artifact.id, archive_format: 'zip', }); let fs = require('fs'); diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 861a6319fe..f9e69b135a 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -5,6 +5,7 @@ on: jobs: deploy: + name: Docs Destroy runs-on: ubuntu-latest steps: - name: Checkout code @@ -22,7 +23,7 @@ jobs: tg_version: "0.58.12" tofu_version: "1.7.1" tg_dir: "deployment/modules/cloudflare/docs" - tg_command: "destroy" + tg_command: "destroy -refresh=false" - name: Comment uses: actions-cool/maintain-one-comment@v3 diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml new file mode 100644 index 0000000000..0c630c9e4b --- /dev/null +++ b/.github/workflows/fix-format.yml @@ -0,0 +1,52 @@ +name: Fix formatting + +on: + pull_request: + types: [labeled] + +jobs: + fix-formatting: + runs-on: ubuntu-latest + if: ${{ github.event.label.name == 'fix:formatting' }} + permissions: + pull-requests: write + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + + - name: 'Checkout' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ steps.generate-token.outputs.token }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: './server/.nvmrc' + + - name: Fix formatting + run: make install-all && make format-all + + - name: Commit and push + uses: EndBug/add-and-commit@v9 + with: + default_author: github_actions + message: 'chore: fix formatting' + + - name: Remove label + uses: actions/github-script@v7 + if: always() + with: + script: | + github.rest.issues.removeLabel({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + name: 'fix:formatting' + }) + diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml new file mode 100644 index 0000000000..754d409613 --- /dev/null +++ b/.github/workflows/pr-label-validation.yml @@ -0,0 +1,21 @@ +name: PR Label Validation + +on: + pull_request_target: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + validate-release-label: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Require PR to have a changelog label + uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + use_regex: true + labels: "changelog:.*" + add_comment: true diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9d50f6f8f9..fc03b24d08 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -29,10 +29,17 @@ jobs: ref: ${{ steps.push-tag.outputs.commit_long_sha }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Checkout uses: actions/checkout@v4 with: - token: ${{ secrets.ORG_RELEASE_TOKEN }} + token: ${{ steps.generate-token.outputs.token }} - name: Install Poetry run: pipx install poetry @@ -44,10 +51,8 @@ jobs: id: push-tag uses: EndBug/add-and-commit@v9 with: - author_name: Alex The Bot - author_email: alex.tran1502@gmail.com - default_author: user_info - message: 'Version ${{ env.IMMICH_VERSION }}' + default_author: github_actions + message: 'chore: version ${{ env.IMMICH_VERSION }}' tag: ${{ env.IMMICH_VERSION }} push: true diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 27392a12bd..196f8faf59 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -10,8 +10,27 @@ concurrency: cancel-in-progress: true jobs: + pre-job: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + mobile: + - 'mobile/**' + - name: Check if we should force jobs to run + id: should_force + run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" + mobile-dart-analyze: name: Run Dart Code Analysis + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run == 'true' }} runs-on: ubuntu-latest @@ -37,6 +56,10 @@ jobs: run: dart format lib/ --set-exit-if-changed working-directory: ./mobile + - 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34a9d984a0..52e0ba7b07 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,8 +10,48 @@ concurrency: cancel-in-progress: true jobs: + pre-job: + runs-on: ubuntu-latest + outputs: + should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_e2e: ${{ steps.found_paths.outputs.e2e == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_mobile: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_e2e_web: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} + should_run_e2e_server_cli: ${{ steps.found_paths.outputs.e2e == 'true' || steps.found_paths.outputs.server == 'true' || steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - id: found_paths + uses: dorny/paths-filter@v3 + with: + filters: | + web: + - 'web/**' + - 'i18n/**' + - 'open-api/typescript-sdk/**' + server: + - 'server/**' + cli: + - 'cli/**' + - 'open-api/typescript-sdk/**' + e2e: + - 'e2e/**' + mobile: + - 'mobile/**' + machine-learning: + - 'machine-learning/**' + + - name: Check if we should force jobs to run + id: should_force + run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" + server-unit-tests: - name: Server + name: Test & Lint Server + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -41,12 +81,14 @@ jobs: run: npm run check if: ${{ !cancelled() }} - - name: Run unit tests & coverage + - name: Run small tests & coverage run: npm run test:cov if: ${{ !cancelled() }} cli-unit-tests: - name: CLI + name: Unit Test CLI + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -85,7 +127,9 @@ jobs: if: ${{ !cancelled() }} cli-unit-tests-win: - name: CLI (Windows) + name: Unit Test CLI (Windows) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: windows-latest defaults: run: @@ -117,7 +161,9 @@ jobs: if: ${{ !cancelled() }} web-unit-tests: - name: Web + name: Test & Lint Web + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_web == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -159,13 +205,74 @@ jobs: run: npm run test:cov if: ${{ !cancelled() }} - e2e-tests: - name: End-to-End Tests + e2e-tests-lint: + name: End-to-End Lint + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_e2e == 'true' }} runs-on: ubuntu-latest defaults: run: working-directory: ./e2e + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: './e2e/.nvmrc' + + - name: Run setup typescript-sdk + run: npm ci && npm run build + working-directory: ./open-api/typescript-sdk + if: ${{ !cancelled() }} + + - name: Install dependencies + run: npm ci + if: ${{ !cancelled() }} + + - name: Run linter + run: npm run lint + if: ${{ !cancelled() }} + + - name: Run formatter + run: npm run format + if: ${{ !cancelled() }} + + - name: Run tsc + run: npm run check + if: ${{ !cancelled() }} + + medium-tests-server: + name: Medium Tests (Server) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} + runs-on: mich + + 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: Run medium tests + if: ${{ !cancelled() }} + run: make test-medium + + e2e-tests-server-cli: + name: End-to-End Tests (Server & CLI) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }} + runs-on: mich + defaults: + run: + working-directory: ./e2e + steps: - name: Checkout code uses: actions/checkout@v4 @@ -191,16 +298,41 @@ jobs: run: npm ci if: ${{ !cancelled() }} - - name: Run linter - run: npm run lint + - name: Docker build + run: docker compose build if: ${{ !cancelled() }} - - name: Run formatter - run: npm run format + - name: Run e2e tests (api & cli) + run: npm run test if: ${{ !cancelled() }} - - name: Run tsc - run: npm run check + e2e-tests-web: + name: End-to-End Tests (Web) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }} + runs-on: mich + defaults: + run: + working-directory: ./e2e + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: './e2e/.nvmrc' + + - name: Run setup typescript-sdk + run: npm ci && npm run build + working-directory: ./open-api/typescript-sdk + if: ${{ !cancelled() }} + + - name: Install dependencies + run: npm ci if: ${{ !cancelled() }} - name: Install Playwright Browsers @@ -211,16 +343,14 @@ jobs: run: docker compose build if: ${{ !cancelled() }} - - name: Run e2e tests (api & cli) - run: npm run test - if: ${{ !cancelled() }} - - name: Run e2e tests (web) run: npx playwright test if: ${{ !cancelled() }} mobile-unit-tests: - name: Mobile + name: Unit Test Mobile + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -234,7 +364,9 @@ jobs: run: flutter test -j 1 ml-unit-tests: - name: Machine Learning + name: Unit Test ML + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} runs-on: ubuntu-latest defaults: run: diff --git a/.gitignore b/.gitignore index 537e048be2..e0544ad8d5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ mobile/openapi/.openapi-generator/FILES open-api/typescript-sdk/build mobile/android/fastlane/report.xml mobile/ios/fastlane/report.xml + +vite.config.js.timestamp-* diff --git a/.gitmodules b/.gitmodules index 8c4cc4e205..d417dc5ba8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "mobile/.isar"] path = mobile/.isar url = https://github.com/isar/isar -[submodule "server/test/assets"] +[submodule "e2e/test-assets"] path = e2e/test-assets url = https://github.com/immich-app/test-assets diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a88b7f3e1..ed3da9f667 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,8 +5,8 @@ "type": "node", "request": "attach", "restart": true, - "port": 9230, - "name": "Immich Server", + "port": 9231, + "name": "Immich API Server", "remoteRoot": "/usr/src/app", "localRoot": "${workspaceFolder}/server" }, @@ -14,8 +14,8 @@ "type": "node", "request": "attach", "restart": true, - "port": 9231, - "name": "Immich Microservices", + "port": 9230, + "name": "Immich Workers", "remoteRoot": "/usr/src/app", "localRoot": "${workspaceFolder}/server" } diff --git a/Makefile b/Makefile index 349a5c5e92..2096cf86df 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,18 @@ test-e2e: docker compose -f ./e2e/docker-compose.yml build npm --prefix e2e run test npm --prefix e2e run test:web +test-medium: + docker run \ + --rm \ + -v ./server/src:/usr/src/app/src \ + -v ./server/test:/usr/src/app/test \ + -v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \ + -v ./server/tsconfig.json:/usr/src/app/tsconfig.json \ + -e NODE_ENV=development \ + immich-server:latest \ + -c "npm ci && npm run test:medium -- --run" +test-medium-dev: + docker exec -it immich_server /bin/sh -c "npm run test:medium" build-all: $(foreach M,$(MODULES),build-$M) ; install-all: $(foreach M,$(MODULES),install-$M) ; diff --git a/README.md b/README.md index 8585457707..0c7b1252ab 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,24 @@
+

- -Català -Español -Français -Italiano -日本語 -한국어 -Deutsch -Nederlands -Türkçe -中文 -Русский -Português Brasileiro -Svenska -العربية - + Català + Español + Français + Italiano + 日本語 + 한국어 + Deutsch + Nederlands + Türkçe + 中文 + Русский + Português Brasileiro + Svenska + العربية + Tiếng Việt + ภาษาไทย

## Disclaimer @@ -92,7 +93,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En | LivePhoto/MotionPhoto backup and playback | Yes | Yes | | Support 360 degree image display | No | Yes | | User-defined storage structure | Yes | Yes | -| Public Sharing | No | Yes | +| Public Sharing | Yes | Yes | | Archive and Favorites | Yes | Yes | | Global Map | Yes | Yes | | Partner Sharing | Yes | Yes | @@ -101,6 +102,8 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En | Offline support | Yes | No | | Read-only gallery | Yes | Yes | | Stacked Photos | Yes | Yes | +| Tags | No | Yes | +| Folder View | No | Yes | ## Translations diff --git a/cli/.eslintignore b/cli/.eslintignore deleted file mode 100644 index 9b1c8b133c..0000000000 --- a/cli/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/dist diff --git a/cli/.eslintrc.cjs b/cli/.eslintrc.cjs deleted file mode 100644 index fe8044df81..0000000000 --- a/cli/.eslintrc.cjs +++ /dev/null @@ -1,28 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prefer-module': 'off', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/no-process-exit': 'off', - 'unicorn/import-style': 'off', - curly: 2, - 'prettier/prettier': 0, - }, -}; diff --git a/cli/.nvmrc b/cli/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/cli/Dockerfile b/cli/Dockerfile index e9c8ab630c..b112382cbb 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 as core +FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff 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/README.md b/cli/README.md index a570a55239..8fa2ace483 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,8 +4,18 @@ Please see the [Immich CLI documentation](https://immich.app/docs/features/comma # For developers +Before building the CLI, you must build the immich server and the open-api client. To build the server run the following in the server folder: + + $ npm install + $ npm run build + +Then, to build the open-api client run the following in the open-api folder: + + $ ./bin/generate-open-api.sh + To run the Immich CLI from source, run the following in the cli folder: + $ npm install $ npm run build $ ts-node . @@ -17,3 +27,4 @@ You can also build and install the CLI using $ npm run build $ npm install -g . +**** diff --git a/cli/eslint.config.mjs b/cli/eslint.config.mjs new file mode 100644 index 0000000000..9115a1feb7 --- /dev/null +++ b/cli/eslint.config.mjs @@ -0,0 +1,61 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 [ + { + ignores: ['eslint.config.mjs', 'dist'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prefer-module': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-process-exit': 'off', + 'unicorn/import-style': 'off', + curly: 2, + 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], + }, + }, +]; diff --git a/cli/package-lock.json b/cli/package-lock.json index 16a1e67a70..7f691935da 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.28", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -17,30 +17,33 @@ "immich": "dist/index.js" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@types/node": "^22.8.1", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", - "vitest-fetch-mock": "^0.3.0", + "vitest-fetch-mock": "^0.4.0", "yaml": "^2.3.1" }, "engines": { @@ -49,14 +52,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.119.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "typescript": "^5.3.3" } }, @@ -714,24 +717,75 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -739,7 +793,7 @@ "strip-json-comments": "^3.1.1" }, "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" @@ -755,6 +809,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -768,48 +835,60 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "levn": "^0.4.1" }, "engines": { - "node": ">=10.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": "*" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -825,11 +904,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@immich/sdk": { "resolved": "../open-api/typescript-sdk", @@ -1014,169 +1101,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1204,6 +1346,13 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", @@ -1229,13 +1378,13 @@ } }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "22.8.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", + "integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.8" } }, "node_modules/@types/normalize-package-data": { @@ -1245,32 +1394,32 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", - "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/type-utils": "7.17.0", - "@typescript-eslint/utils": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1279,27 +1428,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1308,17 +1457,17 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", - "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1326,27 +1475,24 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", - "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.56.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -1354,13 +1500,13 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", - "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1368,23 +1514,23 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", - "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1397,66 +1543,61 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", - "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", - "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz", + "integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -1466,17 +1607,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.3", + "vitest": "2.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1484,11 +1632,40 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", + "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -1497,12 +1674,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.3", "pathe": "^1.1.2" }, "funding": { @@ -1510,13 +1688,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -1524,10 +1703,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -1536,13 +1716,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1551,9 +1731,9 @@ } }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -1568,6 +1748,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -1618,21 +1799,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1723,6 +1895,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1761,6 +1934,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -1793,6 +1967,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -1892,15 +2067,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", - "dev": true, - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1937,6 +2103,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1947,31 +2114,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2060,58 +2202,64 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "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" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -2191,30 +2339,18 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", "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" @@ -2232,21 +2368,43 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "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", + "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/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2255,17 +2413,31 @@ } }, "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.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.1.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.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2288,6 +2460,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2309,6 +2482,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -2322,29 +2496,6 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2361,6 +2512,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -2404,15 +2556,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -2443,24 +2596,25 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -2478,12 +2632,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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==", - "dev": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2507,27 +2655,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2561,36 +2688,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2641,15 +2745,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2693,22 +2788,6 @@ "node": ">=8" } }, - "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==", - "dev": 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==", - "dev": true - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2778,27 +2857,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2904,7 +2962,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -2929,6 +2988,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -2979,13 +3039,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", @@ -3028,12 +3086,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3054,18 +3106,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "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", @@ -3101,10 +3141,11 @@ } }, "node_modules/mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.0.tgz", + "integrity": "sha512-3ROPnEMgBOkusBMYQUW2rnT3wZwsgfOKzJDLvx/TZ7FL1WmWvwSwn3j4aDR5fLDGtgcc1WF0Z1y0di7c9L4FKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.0.0" } @@ -3139,26 +3180,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "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==", - "dev": 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-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -3186,57 +3207,6 @@ "semver": "bin/semver" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3338,15 +3308,6 @@ "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==", - "dev": 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", @@ -3378,35 +3339,27 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -3431,9 +3384,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -3452,8 +3405,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3497,21 +3450,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -3712,68 +3661,12 @@ "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==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "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==", - "dev": 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/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -3785,19 +3678,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -3874,21 +3770,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3991,18 +3878,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4089,10 +3964,18 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -4113,10 +3996,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -4141,12 +4025,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true - }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -4197,22 +4075,10 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4224,10 +4090,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.0.13", @@ -4279,15 +4146,15 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -4306,6 +4173,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -4323,6 +4191,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -4335,15 +4206,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -4357,10 +4228,11 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", @@ -4376,29 +4248,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4413,8 +4286,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", "happy-dom": "*", "jsdom": "*" }, @@ -4440,36 +4313,18 @@ } }, "node_modules/vitest-fetch-mock": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.3.0.tgz", - "integrity": "sha512-g6upWcL8/32fXL43/5f4VHcocuwQIi9Fj5othcK9gPO8XqSEGtnIZdenr2IaipDr61ReRFt+vaOEgo8jiUUX5w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vitest-fetch-mock/-/vitest-fetch-mock-0.4.1.tgz", + "integrity": "sha512-Y6VEV2AgJps1t9NUdhID/vUwarAuhOkPHShfoEruIlQr5+O31hgJ4YmZpU8kVWD3KQjEyZqPeMibWehd7rMq+A==", "dev": true, - "dependencies": { - "cross-fetch": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": ">=14.14.0" + "node": ">=18.0.0" }, "peerDependencies": { "vitest": ">=2.0.0" } }, - "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==", - "dev": true - }, - "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==", - "dev": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4598,16 +4453,10 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "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==", - "dev": true - }, "node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", "dev": true, "license": "ISC", "bin": { diff --git a/cli/package.json b/cli/package.json index 8efbd0652b..7e1eaa8d1c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.28", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", @@ -13,30 +13,33 @@ "cli" ], "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@types/node": "^22.8.1", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", - "vitest-fetch-mock": "^0.3.0", + "vitest-fetch-mock": "^0.4.0", "yaml": "^2.3.1" }, "scripts": { @@ -64,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/cli/src/utils.spec.ts b/cli/src/utils.spec.ts index 0094b329b8..3e7e55fcb6 100644 --- a/cli/src/utils.spec.ts +++ b/cli/src/utils.spec.ts @@ -115,17 +115,7 @@ const tests: Test[] = [ '/albums/image3.jpg': true, }, }, - { - test: 'should support globbing paths', - options: { - pathsToCrawl: ['/photos*'], - }, - files: { - '/photos1/image1.jpg': true, - '/photos2/image2.jpg': true, - '/images/image3.jpg': false, - }, - }, + { test: 'should crawl a single path without trailing slash', options: { diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 67948e0bd2..7bbbb5615b 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -141,25 +141,21 @@ export const crawl = async (options: CrawlOptions): Promise => { } } - let searchPattern: string; - if (patterns.length === 1) { - searchPattern = patterns[0]; - } else if (patterns.length === 0) { + if (patterns.length === 0) { return crawledFiles; - } else { - searchPattern = '{' + patterns.join(',') + '}'; } - if (recursive) { - searchPattern = searchPattern + '/**/'; - } + const searchPatterns = patterns.map((pattern) => { + let escapedPattern = pattern; + if (recursive) { + escapedPattern = escapedPattern + '/**'; + } + return `${escapedPattern}/*.{${extensions.join(',')}}`; + }); - searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`; - - const globbedFiles = await glob(searchPattern, { + const globbedFiles = await glob(searchPatterns, { absolute: true, caseSensitiveMatch: false, - onlyFiles: true, dot: includeHidden, ignore: [`**/${exclusionPattern}`], }); diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 4774e1cacf..995f5d5b69 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.38.0" - constraints = "4.38.0" + version = "4.45.0" + constraints = "4.45.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:/CGpnYMkLRDmqn4iAsh/jg7ELZ6QExUw03VdjKZyK5M=", + "h1:82C/ryqwQvxhBINYOOyF5ZzPW/k4zJ/RYT13eCdPgEc=", + "h1:8Wu1D7ZwbLGdHakLRAzoAJ5VqZ8I14qzkPv1OGNfIlg=", + "h1:CVq0CAibeueOuiNk0UQtwZvMLMof33n1BgskFPOymrk=", + "h1:FSS5Kq+L+CX1zARy8PhaF8edBFNgsLtds4Uo8MwJiK8=", + "h1:L4qsorLII7f8xSFmv6JOoWfLWDunWQEpK964Bxk7mtM=", + "h1:StO3PV5PDskSCnhoHhWHOPxu6hbzJUQggfLgOSkvhwg=", + "h1:Tjo+Er9ets5YrTRIdP9LBmi4p89nL/W+A7r8a1MM9nI=", + "h1:XIwT+AWvks1LTytePM9zls+O8ItxoqCfPOgHwuH9ivQ=", + "h1:aOXn/zuM1+5GGy/SSRx8q4EYCSTFE9Tr0twHPIf5/KE=", + "h1:lb+YcuZ4guYd8zE51vgSnDsRAD9IV00Z15l1i1X52s8=", + "h1:pYwNXGjfXA2rUEmotGMLWgmavT9D2rdHnV3TpuIK3ko=", + "h1:q1qrnPq6KkljwBrugCwzb7f0SVP4Lzkfh+EOLARY9V8=", + "h1:v9sL4cZLTV5Gu2004DDyy7209gT0JmudBCAD0WCr/JE=", + "zh:00be2a6adc76615a368491c7a026098103b6286deb31e3cfb037365dd39f095f", + "zh:05bd072e6119f7a5abff05c6064001f745473119a956586cf77ae843cf55d666", + "zh:228bbe61345c4e8e0bc6b698b4b9652abff65662ee72ede2aecb4c3efb91b243", + "zh:2948aeefe71ba041c94082cf931ecc95510d93af0a61d0a287880f5b9d24b11a", + "zh:5dfc2c5e95843ca54957212ee3ecb7ff06f2cf60bfd6ca278b5249fd70ac18f5", + "zh:69922cb45559b0b0544b9c2d31ed2d0fac9121faa75bc2f523484785b45d8e2b", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:9d83a0cbf72327286f7dbd63cd4af89059c648163fe6ed21b1df768e0518d445", + "zh:a8e1982945822c7d7aaa6ba8602c7247d1a3fad15d612f30eb323491a637bf8d", + "zh:c6d41ebd69ddb23e3dad49a0ebf1da5a9c7d8706a4f55d953115d371f407928b", + "zh:d03e5442b12846c2737f099d30cd23d9f85a0c6d65437ccb44819f9a6c4e1d7f", + "zh:d446f2e1186b35037aea03b0e27d8b032d2f069f194f84b3f0e2907b3a79a955", + "zh:e4d7549a4c856524e01f3dd4d69f57119ea205f7a0fa38dcfe154475b4ae9258", + "zh:e64b8915cb9686f85e77115bd674f2faf4f29880688067d7d0f1376566fdb3b0", + "zh:f046efdc55e6385cdd69baaa06a929bef9fe6809d373b0d2d6c7df8f8c23eddc", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index b7c70f1c21..85e095f195 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.38.0" + version = "4.45.0" } } } diff --git a/deployment/modules/cloudflare/docs-release/domain.tf b/deployment/modules/cloudflare/docs-release/domain.tf index a8e93b8dd5..0602045f71 100644 --- a/deployment/modules/cloudflare/docs-release/domain.tf +++ b/deployment/modules/cloudflare/docs-release/domain.tf @@ -9,6 +9,6 @@ resource "cloudflare_record" "immich_app_release_domain" { proxied = true ttl = 1 type = "CNAME" - value = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_pages_hostname + content = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_pages_hostname zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 4774e1cacf..995f5d5b69 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.38.0" - constraints = "4.38.0" + version = "4.45.0" + constraints = "4.45.0" hashes = [ - "h1:+27KAHKHBDvv3dqyJv5vhtdKQZJzoZXoMqIyronlHNw=", - "h1:/uV9RgOUhkxElkHhWs8fs5ZbX9vj6RCBfP0oJO0JF30=", - "h1:1DNAdMugJJOAWD/XYiZenYYZLy7fw2ctjT4YZmkRCVQ=", - "h1:1wn4PmCLdT7mvd74JkCGmJDJxTQDkcxc+1jNbmwnMHA=", - "h1:BIHB4fBxHg2bA9KbL92njhyctxKC8b6hNDp60y5QBss=", - "h1:HCQpvKPsMsR4HO5eDqt+Kao7T7CYeEH7KZIO7xMcC6M=", - "h1:HTomuzocukpNLwtWzeSF3yteCVsyVKbwKmN66u9iPac=", - "h1:YDxsUBhBAwHSXLzVwrSlSBOwv1NvLyry7s5SfCV7VqQ=", - "h1:dchVhxo+Acd1l2RuZ88tW9lWj4422QMfgtxKvKCjYrw=", - "h1:eypa+P4ZpsEGMPFuCE+6VkRefu0TZRFmVBOpK+PDOPY=", - "h1:f3yjse2OsRZj7ZhR7BLintJMlI4fpyt8HyDP/zcEavw=", - "h1:mSJ7xj8K+xcnEmGg7lH0jjzyQb157wH94ULTAlIV+HQ=", - "h1:tt+2J2Ze8VIdDq2Hr6uHlTJzAMBRpErBwTYx0uD5ilE=", - "h1:uQW8SKxmulqrAisO+365mIf2FueINAp5PY28bqCPCug=", - "zh:171ab67cccceead4514fafb2d39e4e708a90cce79000aaf3c29aab7ed4457071", - "zh:18aa7228447baaaefc49a43e8eff970817a7491a63d8937e796357a3829dd979", - "zh:2cbaab6092e81ba6f41fa60a50f14e980c8ec327ee11d0b21f16a478be4b7567", - "zh:53b8e49c06f5b31a8c681f8c0669cf43e78abe71657b8182a221d096bb514965", - "zh:6037cfc60b4b647aabae155fcb46d649ed7c650e0287f05db52b2068f1e27c8a", - "zh:62460982ce1a869eebfca675603fbbd50416cf6b69459fb855bfbe5ae2b97607", - "zh:65f6f3a8470917b6398baa5eb4f74b3932b213eac7c0202798bfad6fd1ee17df", + "h1:/CGpnYMkLRDmqn4iAsh/jg7ELZ6QExUw03VdjKZyK5M=", + "h1:82C/ryqwQvxhBINYOOyF5ZzPW/k4zJ/RYT13eCdPgEc=", + "h1:8Wu1D7ZwbLGdHakLRAzoAJ5VqZ8I14qzkPv1OGNfIlg=", + "h1:CVq0CAibeueOuiNk0UQtwZvMLMof33n1BgskFPOymrk=", + "h1:FSS5Kq+L+CX1zARy8PhaF8edBFNgsLtds4Uo8MwJiK8=", + "h1:L4qsorLII7f8xSFmv6JOoWfLWDunWQEpK964Bxk7mtM=", + "h1:StO3PV5PDskSCnhoHhWHOPxu6hbzJUQggfLgOSkvhwg=", + "h1:Tjo+Er9ets5YrTRIdP9LBmi4p89nL/W+A7r8a1MM9nI=", + "h1:XIwT+AWvks1LTytePM9zls+O8ItxoqCfPOgHwuH9ivQ=", + "h1:aOXn/zuM1+5GGy/SSRx8q4EYCSTFE9Tr0twHPIf5/KE=", + "h1:lb+YcuZ4guYd8zE51vgSnDsRAD9IV00Z15l1i1X52s8=", + "h1:pYwNXGjfXA2rUEmotGMLWgmavT9D2rdHnV3TpuIK3ko=", + "h1:q1qrnPq6KkljwBrugCwzb7f0SVP4Lzkfh+EOLARY9V8=", + "h1:v9sL4cZLTV5Gu2004DDyy7209gT0JmudBCAD0WCr/JE=", + "zh:00be2a6adc76615a368491c7a026098103b6286deb31e3cfb037365dd39f095f", + "zh:05bd072e6119f7a5abff05c6064001f745473119a956586cf77ae843cf55d666", + "zh:228bbe61345c4e8e0bc6b698b4b9652abff65662ee72ede2aecb4c3efb91b243", + "zh:2948aeefe71ba041c94082cf931ecc95510d93af0a61d0a287880f5b9d24b11a", + "zh:5dfc2c5e95843ca54957212ee3ecb7ff06f2cf60bfd6ca278b5249fd70ac18f5", + "zh:69922cb45559b0b0544b9c2d31ed2d0fac9121faa75bc2f523484785b45d8e2b", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8b5cebe64bf04105a49178a165b6a8800a9a33bae6767143a47fe4977755f805", - "zh:a5596635db0993ee3c3060fbc2227d91b239466e96d2d82642625a5aa2486988", - "zh:b3a9c63038441f13c311fd4b2c7e69e571445e5a7365a20c7cc9046b7e6c8aba", - "zh:b585e7e4d7648a540b14b9182819214896ca9337729eeb1f2034833b17db754d", - "zh:d2c3c545318ac8542369e9fc8228e29ee585febdf203a450fad3e0eded71ce02", - "zh:e95dd2d6c3525073af47d47b763cb81b6a51b20cabf76f789c69328922da9ecf", - "zh:eee6e590b36d6c6168a7daae8afa74a8721fd7aa9f62a710f04a311975100722", + "zh:9d83a0cbf72327286f7dbd63cd4af89059c648163fe6ed21b1df768e0518d445", + "zh:a8e1982945822c7d7aaa6ba8602c7247d1a3fad15d612f30eb323491a637bf8d", + "zh:c6d41ebd69ddb23e3dad49a0ebf1da5a9c7d8706a4f55d953115d371f407928b", + "zh:d03e5442b12846c2737f099d30cd23d9f85a0c6d65437ccb44819f9a6c4e1d7f", + "zh:d446f2e1186b35037aea03b0e27d8b032d2f069f194f84b3f0e2907b3a79a955", + "zh:e4d7549a4c856524e01f3dd4d69f57119ea205f7a0fa38dcfe154475b4ae9258", + "zh:e64b8915cb9686f85e77115bd674f2faf4f29880688067d7d0f1376566fdb3b0", + "zh:f046efdc55e6385cdd69baaa06a929bef9fe6809d373b0d2d6c7df8f8c23eddc", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index b7c70f1c21..85e095f195 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.38.0" + version = "4.45.0" } } } diff --git a/deployment/modules/cloudflare/docs/domain.tf b/deployment/modules/cloudflare/docs/domain.tf index 6d00f26b60..a28fb4c0f8 100644 --- a/deployment/modules/cloudflare/docs/domain.tf +++ b/deployment/modules/cloudflare/docs/domain.tf @@ -9,7 +9,7 @@ resource "cloudflare_record" "immich_app_branch_subdomain" { proxied = true ttl = 1 type = "CNAME" - value = "${replace(var.prefix_name, "/\\/|\\./", "-")}.${local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_subdomain : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_subdomain}" + content = "${replace(var.prefix_name, "/\\/|\\./", "-")}.${local.is_release ? data.terraform_remote_state.cloudflare_account.outputs.immich_app_archive_pages_project_subdomain : data.terraform_remote_state.cloudflare_account.outputs.immich_app_preview_pages_project_subdomain}" zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id } @@ -18,7 +18,7 @@ output "immich_app_branch_subdomain" { } output "immich_app_branch_pages_hostname" { - value = cloudflare_record.immich_app_branch_subdomain.value + value = cloudflare_record.immich_app_branch_subdomain.content } output "pages_project_name" { diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 85eec31fe3..26e58f18d7 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -36,16 +36,22 @@ services: IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849 IMMICH_BUILD_IMAGE: development IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server + IMMICH_THIRD_PARTY_SOURCE_URL: https://github.com/immich-app/immich/ + IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues + IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://immich.app/docs + IMMICH_THIRD_PARTY_SUPPORT_URL: https://immich.app/docs/third-party ulimits: nofile: soft: 1048576 hard: 1048576 ports: - - 3001:3001 - 9230:9230 + - 9231:9231 depends_on: - redis - database + healthcheck: + disable: false immich-web: container_name: immich_web @@ -60,6 +66,7 @@ services: - 24678:24678 volumes: - ../web:/usr/src/app + - ../i18n:/usr/src/i18n - ../open-api/:/usr/src/open-api/ - /usr/src/app/node_modules ulimits: @@ -91,10 +98,12 @@ services: depends_on: - database restart: unless-stopped + healthcheck: + disable: false redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 @@ -134,7 +143,7 @@ services: 'wal_compression=on', ] - # set IMMICH_METRICS=true in .env to enable metrics + # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics # immich-prometheus: # container_name: immich_prometheus # ports: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 9e97aad004..48d4328c85 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -16,11 +16,13 @@ services: env_file: - .env ports: - - 2283:3001 + - 2283:2283 depends_on: - redis - database restart: always + healthcheck: + disable: false immich-machine-learning: container_name: immich_machine_learning @@ -40,10 +42,12 @@ services: env_file: - .env restart: always + healthcheck: + disable: false redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -67,15 +71,30 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] restart: always - # set IMMICH_METRICS=true in .env to enable metrics + # set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics immich-prometheus: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:f20d3127bf2876f4a1df76246fca576b41ddf1125ed1c546fbd8b16ea55117e6 + image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus @@ -87,7 +106,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.1.3-ubuntu@sha256:e10453733015f31103cb530425f32c994816b50102886fa885dafea2c50a711c + image: grafana/grafana:11.3.0-ubuntu@sha256:51587e148ac0214d7938e7f3fe8512182e4eb6141892a3ffb88bba1901b49285 volumes: - grafana-data:/var/lib/grafana diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f3206150af..979343364c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,11 +22,13 @@ services: env_file: - .env ports: - - 2283:3001 + - '2283:2283' depends_on: - redis - database restart: always + healthcheck: + disable: false immich-machine-learning: container_name: immich_machine_learning @@ -41,10 +43,12 @@ services: env_file: - .env restart: always + healthcheck: + disable: false redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -65,7 +69,22 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] restart: always volumes: diff --git a/docker/example.env b/docker/example.env index 99b1a9bbd4..9ad3af3c0e 100644 --- a/docker/example.env +++ b/docker/example.env @@ -12,6 +12,7 @@ DB_DATA_LOCATION=./postgres IMMICH_VERSION=release # Connection secret for postgres. You should change it to a random password +# Please use only the characters `A-Za-z0-9`, without special characters or spaces DB_PASSWORD=postgres # The values below this line do not need to be changed diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index bd4e2a46b8..33fb7b3c06 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -51,5 +51,4 @@ services: volumes: - /usr/lib/wsl:/usr/lib/wsl environment: - - LD_LIBRARY_PATH=/usr/lib/wsl/lib - LIBVA_DRIVER_NAME=d3d12 diff --git a/docs/.nvmrc b/docs/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index feb35a02db..b328d3a047 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -52,14 +52,25 @@ On iOS (iPhone and iPad), the operating system determines if a particular app ca - Disable Background App Refresh for apps that don't need background tasks to run. This will reduce the competition for background task invocation for Immich. - Use the Immich app more often. +### Why are features not working with a self-signed cert or mTLS? + +Due to limitations in the upstream app/video library, using a self-signed TLS certificate or mutual TLS may break video playback or asset upload (both foreground and/or background). +We recommend using a real SSL certificate from a free provider, for example [Let's Encrypt](https://letsencrypt.org/). + --- ## Assets ### Does Immich change the file? -No, Immich does not touch the original file under any circumstances, -all edited metadata are saved in the companion sidecar file and the database. +No, Immich does not modify the original files. +All edited metadata is saved in companion `.xmp` sidecar files and the database. +However, Immich will delete original files that have been trashed when the trash is emptied in the Immich UI. + +### Why do my file names appear as a random string in the file manager? + +When Storage Template is off (default) Immich saves the file names in a random string (also known as random UUIDs) to prevent duplicate file names. To retrieve the original file names, you must enable the Storage Template and then run the STORAGE TEMPLATE MIGRATION job. +It is recommended to read about [Storage Template](https://immich.app/docs/administration/storage-template) before activation. ### Can I add my existing photo library? @@ -157,13 +168,26 @@ We haven't implemented an official mechanism for creating albums from external l Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries. +### Why are my edits to files not being saved in read-only external libraries? + +Images in read-write external libraries (the default) can be edited as normal. +In read-only libraries (`:ro` in the `docker-compose.yml`), Immich is unable to create the `.xmp` sidecar files to store edited file metadata. +For this reason, the metadata (timestamp, location, description, star rating, etc.) cannot be edited for files in read-only external libraries. + +### How are deletions of files handled in external libraries? + +Immich will attempt to delete original files that have been trashed when the trash is emptied. +In read-write external libraries (the default), Immich will delete the original file. +In read-only libraries (`:ro` in the `docker-compose.yml`), files can still be trashed in the UI. +However, when the trash is emptied, the files will re-appear in the main timeline since Immich is unable to delete the original file. + --- ## Machine Learning ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? @@ -294,6 +318,12 @@ You need to enable WebSockets on your reverse proxy. Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md). +### How can I reduce the log verbosity of Redis? + +To decrease Redis logs, you can add the following line to the `redis:` section of the `docker-compose.yml`: + +` command: redis-server --loglevel warning` + ### How can I run Immich as a non-root user? You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service. @@ -303,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co - `immich-machine-learning:/.cache` - `redis:/data` -The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`. +The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning. + +:::note Docker Compose Volumes +The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts. +::: For a further hardened system, you can add the following block to every container except for `immich_postgres`. diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 5c6ae47e43..70f393e6e1 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -21,6 +21,19 @@ The recommended way to backup and restore the Immich database is to use the `pg_ It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored. ::: +### Automatic Database Backups + +Immich will automatically create database backups by default. The backups are stored in `UPLOAD_LOCATION/backups`. +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. + +#### 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. +Then please follow the steps in the following section for restoring the database. + +### Manual Backup and Restore + @@ -29,34 +42,38 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre ``` ```bash title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. -docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server -sleep 10 # Wait for Postgres server to start up +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" \ | 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 --username=postgres # Restore Backup -docker compose up -d # Start remainder of Immich apps +| docker exec -i immich_postgres psql --username=postgres # Restore Backup +docker compose up -d # Start remainder of Immich apps ``` ```powershell title='Backup' -docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "\path\to\backup\dump.sql" +docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql" ``` ```powershell title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. -docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server -sleep 10 # Wait for Postgres server to start up -gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup -docker compose up -d # Start remainder of Immich apps +sleep 10 # Wait for Postgres server to start up +# Check the database user if you deviated from the default +gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup +docker compose up -d # Start remainder of Immich apps ``` @@ -68,6 +85,8 @@ Note that for the database restore to proceed properly, it requires a completely Some deployment methods make it difficult to start the database without also starting the server or microservices. In these cases, you may set the environmental variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Note that both the server and microservices must have this variable set to prevent the migrations from running. Be sure to remove this variable and restart the services after the database is restored. ::: +### Automatic Database Backups + The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following: ```yaml @@ -97,6 +116,7 @@ services: Then you can restore with the same command but pointed at the latest dump. ```bash title='Automated Restore' +# Be sure to check the username if you changed it from default gunzip < db_dumps/last/immich-latest.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 --username=postgres @@ -157,7 +177,7 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele - The Immich database containing all the information to allow the system to function properly. **Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version. - - Stored in `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! @@ -191,7 +211,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO - Stored in `UPLOAD_LOCATION/profile/`. - **Thumbs Images:** - Preview images (blurred, small, large) for each asset and thumbnails for recognized faces. - - Stored in `UPLOCAD_LOCATION/thumbs/`. + - Stored in `UPLOAD_LOCATION/thumbs/`. - **Encoded Assets:** - Videos that have been re-encoded from the original for wider compatibility. The original is not removed. - Stored in `UPLOAD_LOCATION/encoded-video/`. @@ -203,7 +223,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO - The Immich database containing all the information to allow the system to function properly. **Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version. - - Stored in `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 4a2a0b5a83..93b1051053 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve ## SMTP settings -You can access the settings panel from the web at `Administration -> Settings -> Notification settings` +You can access the settings panel from the web at `Administration -> Settings -> Notification settings`. -Under Email, enter the following details to connect with SMTP servers. +Under Email, enter the required details to connect with an SMTP server. -You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. - - +You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. ## User's notifications settings diff --git a/docs/docs/administration/img/admin-jobs.png b/docs/docs/administration/img/admin-jobs.png deleted file mode 100644 index 096bce4354..0000000000 Binary files a/docs/docs/administration/img/admin-jobs.png and /dev/null differ diff --git a/docs/docs/administration/img/admin-jobs.webp b/docs/docs/administration/img/admin-jobs.webp new file mode 100644 index 0000000000..15580c3c9c Binary files /dev/null and b/docs/docs/administration/img/admin-jobs.webp differ diff --git a/docs/docs/administration/img/email-settings.png b/docs/docs/administration/img/email-settings.png deleted file mode 100644 index a0d7135426..0000000000 Binary files a/docs/docs/administration/img/email-settings.png and /dev/null differ diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index ff74ea4673..fde39a2e3a 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -22,7 +22,7 @@ Copy the entire `immich-server` block as a new service and make the following ch - container_name: immich_server ... - ports: -- - 2283:3001 +- - 2283:2283 + immich-microservices: + container_name: immich_microservices ``` @@ -52,4 +52,4 @@ Additionally, some jobs run on a schedule, which is every night at midnight. Thi Storage Migration job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library. ::: - + diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index ab317787bc..2dc6990944 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -3,7 +3,7 @@ This page contains details about using OAuth in Immich. :::tip -Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. +Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobile Redirect URI](#mobile-redirect-uri) for an alternative solution. ::: ## Overview @@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI]( Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including: - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) -- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) +- [Authelia](https://www.authelia.com/integration/openid-connect/immich/) - [Okta](https://www.okta.com/openid-connect/) - [Google](https://developers.google.com/identity/openid-connect/openid-connect) @@ -30,7 +30,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured The **Sign-in redirect URIs** should include: - - `app.immich:/` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) + - `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx) - `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client - `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client @@ -38,7 +38,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured Mobile - - `app.immich:/` (You **MUST** include this for iOS and Android mobile apps to work properly) + - `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly) Localhost @@ -96,16 +96,16 @@ When Auto Launch is enabled, the login page will automatically redirect the user ## Mobile Redirect URI -The redirect URI for the mobile app is `app.immich:/`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: +The redirect URI for the mobile app is `app.immich:///oauth-callback`, which is a [Custom Scheme](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app). If this custom scheme is an invalid redirect URI for your OAuth Provider, you can work around this by doing the following: -1. Configure an http(s) endpoint to forwards requests to `app.immich:/` +1. Configure an http(s) endpoint to forwards requests to `app.immich:///oauth-callback` 2. Whitelist the new endpoint as a valid redirect URI with your provider. 3. Specify the new endpoint as the `Mobile Redirect URI Override`, in the OAuth settings. With these steps in place, you should be able to use OAuth from the [Mobile App](/docs/features/mobile-app.mdx) without a custom scheme redirect URI. :::info -Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:/`, and can be used for step 1. +Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to forward requests to `app.immich:///oauth-callback`, and can be used for step 1. ::: ## Example Configuration @@ -154,21 +154,21 @@ Configuration of Authorised redirect URIs (Google Console) Configuration of OAuth in Immich System Settings -| Setting | Value | -| ---------------------------- | ------------------------------------------------------------------------------------------------------ | -| Issuer URL | [https://accounts.google.com](https://accounts.google.com) | -| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | -| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | -| Scope | openid email profile | -| Signing Algorithm | RS256 | -| Storage Label Claim | preferred_username | -| Storage Quota Claim | immich_quota | -| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | -| Button Text | Sign in with Google (optional) | -| Auto Register | Enabled (optional) | -| Auto Launch | Enabled | -| Mobile Redirect URI Override | Enabled (required) | -| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) | +| Setting | Value | +| ---------------------------- | ---------------------------------------------------------------------------- | +| Issuer URL | `https://accounts.google.com` | +| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com | +| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (0 for unlimited quota) | +| Button Text | Sign in with Google (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled | +| Mobile Redirect URI Override | Enabled (required) | +| Mobile Redirect URI | `https://example.immich.app/api/oauth/mobile-redirect` | diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index b5028c788e..798555975f 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -13,9 +13,9 @@ Running with a pre-existing Postgres server can unlock powerful administrative f You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`. :::note -Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. +Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. Postgres 17 is nominally compatible, but pgvecto.rs does not have prebuilt images or packages for it as of writing. -Make sure the installed version of pgvecto.rs is compatible with your version of Immich. For example, if your Immich version uses the dedicated database image `tensorchord/pgvecto-rs:pg14-v0.2.1`, you must install pgvecto.rs `>= 0.2.1, < 0.3.0`. +Make sure the installed version of pgvecto.rs is compatible with your version of Immich. The current accepted range for pgvecto.rs is `>= 0.2.0, < 0.4.0`. ::: ## Specifying the connection URL diff --git a/docs/docs/administration/repair-page.md b/docs/docs/administration/repair-page.md index f230c6d582..4246c7e39c 100644 --- a/docs/docs/administration/repair-page.md +++ b/docs/docs/administration/repair-page.md @@ -1,5 +1,9 @@ # Repair Page +:::warning +This feature is currently disabled and will be reworked in the near future. +::: + The repair page is designed to give information to the system administrator about files that are not tracked, or offline paths. ## Natural State diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 1d2488f119..25762ad7f1 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -40,6 +40,26 @@ server { } ``` +#### Compatibility with Let's Encrypt + +In the event that your nginx configuration includes a section for Let's Encrypt, it's likely that you have a segment similar to the following: + +```nginx +location ~ /.well-known { + ... +} +``` + +This particular `location` directive can inadvertently prevent mobile clients from reaching the `/.well-known/immich` path, which is crucial for discovery. Usual error message for this case is: "Your app major version is not compatible with the server". To remedy this, you should introduce an additional location block specifically for this path, ensuring that requests are correctly proxied to the Immich server: + +```nginx +location = /.well-known/immich { + proxy_pass http://:2283; +} +``` + +By doing so, you'll maintain the functionality of Let's Encrypt while allowing mobile clients to access the necessary Immich path without obstruction. + ### Caddy example config As an alternative to nginx, you can also use [Caddy](https://caddyserver.com/) as a reverse proxy (with automatic HTTPS configuration). Below is an example config. @@ -64,3 +84,43 @@ Below is an example config for Apache2 site configuration. ProxyPreserveHost On ``` + +### Traefik Proxy example config + +The example below is for Traefik version 3. + +The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed. + +`traefik.yaml` + +```yaml +[...] +entryPoints: + websecure: + address: :443 + # this section needs to be added + transport: + respondingTimeouts: + readTimeout: 600s + idleTimeout: 600s + writeTimeout: 600s +``` + +The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. + +`docker-compose.yml` + +```yaml +services: + immich-server: + [...] + labels: + traefik.enable: true + # increase readingTimeouts for the entrypoint used here + traefik.http.routers.immich.entrypoints: websecure + traefik.http.routers.immich.rule: Host(`immich.your-domain.com`) + traefik.http.services.immich.loadbalancer.server.port: 2283 +``` + +Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done +by adding the Traefik network to the `immich-server`. diff --git a/docs/docs/administration/server-stats.md b/docs/docs/administration/server-stats.md index b77037e4ce..eb5f72a41d 100644 --- a/docs/docs/administration/server-stats.md +++ b/docs/docs/administration/server-stats.md @@ -7,7 +7,7 @@ If a storage quota has been defined for the user, the usage number will be displ ::: :::info External library -External library is not included in the storage quota. +External libraries are not included in the storage quota. ::: diff --git a/docs/docs/administration/system-integrity.md b/docs/docs/administration/system-integrity.md new file mode 100644 index 0000000000..5440e42489 --- /dev/null +++ b/docs/docs/administration/system-integrity.md @@ -0,0 +1,47 @@ +# System Integrity + +## Folder checks + +:::info +The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/` +::: + +When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include: + +- Creating an initial hidden file (`.immich`) in each folder +- Reading a hidden file (`.immich`) in each folder +- Overwriting a hidden file (`.immich`) in each folder + +The checks are designed to catch the following situations: + +- Incorrect permissions (cannot read/write files) +- Missing volume mount (`.immich` files should exist, but are missing) + +### Common issues + +:::note +`.immich` files serve as markers and help keep track of volume mounts being used by Immich. Except for the situations listed below, they should never be manually created or deleted. +::: + +#### Missing `.immich` files + +``` +Verifying system mount folder checks (enabled=true) +... +ENOENT: no such file or directory, open 'upload/encoded-video/.immich' +``` + +The above error messages show that the server has previously (successfully) written `.immich` files to each folder, but now does not detect them. This could be because any of the following: + +- Permission error - unable to read the file, but it exists +- File does not exist - volume mount has changed and should be corrected +- File does not exist - user manually deleted it and should be manually re-created (`touch .immich`) +- File does not exist - user restored from a backup, but did not restore each folder (user should restore all folders or manually create `.immich` in any missing folders) + +### Ignoring the checks + +The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable: + +``` +IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true +``` diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index ab8abe05c9..9f35ed1010 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -104,7 +104,7 @@ You can choose to disable a certain type of machine learning, for example smart ### Smart Search -The smart search settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the +The [smart search](/docs/features/smart-search) settings are designed to allow the search tool to be used using [CLIP](https://openai.com/research/clip) models that [can be changed](/docs/FAQ#can-i-use-a-custom-clip-model), different models will necessarily give better results but may consume more processing power, when changing a model it is mandatory to re-run the Smart Search job on all images to fully apply the change. :::info Internet connection @@ -113,15 +113,23 @@ After downloading, there is no need for Immich to connect to the network Unless version checking has been enabled in the settings. ::: +### Duplicate Detection + +Use CLIP embeddings to find likely duplicates. The maximum detection distance can be configured in order to improve / reduce the level of accuracy. + +- **Maximum detection distance -** Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives. + ### Facial Recognition Under these settings, you can change the facial recognition settings Editable settings: -- **Facial Recognition Model -** Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model. -- **Min Detection Score -** Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives. -- **Max Recognition Distance -** Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible. -- **Min Recognized Faces -** The minimum number of recognized faces for a person to be created (AKA: Core face). Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person. +- **Facial Recognition Model** +- **Min Detection Score** +- **Max Recognition Distance** +- **Min Recognized Faces** + +You can learn more about these options on the [Facial Recognition page](/docs/features/facial-recognition#how-face-detection-works) :::info When changing the values in Min Detection Score, Max Recognition Distance, and Min Recognized Faces. @@ -153,7 +161,7 @@ SMTP server setup, for user creation notifications, new albums, etc. More inform ### External Domain -When set, will override the domain name used when viewing and copying a shared link. +Overrides the domain name in shared links and email notifications. The URL should not include a trailing slash. ### Welcome Message diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index cf004a1119..7b5debef4c 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -3,6 +3,7 @@ sidebar_position: 1 --- import AppArchitecture from './img/app-architecture.png'; +import MobileArchitecture from './img/immich_mobile_architecture.svg'; # Architecture @@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for ### Mobile App -The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. +The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview: + + + +The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. +Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). +Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. +The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! ### Web Client diff --git a/docs/docs/developer/directories.md b/docs/docs/developer/directories.md index 3ec483294a..409353e2c4 100644 --- a/docs/docs/developer/directories.md +++ b/docs/docs/developer/directories.md @@ -15,7 +15,7 @@ Our [GitHub Repository](https://github.com/immich-app/immich) is a [monorepo](ht | `design/` | Screenshots and logos for the README | | `docs/` | Source code for the [https://immich.app](https://immich.app) website | | `machine-learning/` | Source code for the `immich-machine-learning` docker image | -| `misc/release/` | Scripts for version pumps and draft releases | +| `misc/release/` | Scripts for version bumps and draft releases | | `mobile/` | Source code for the mobile app, both Android and iOS | | `server/` | Source code for the `immich-server` docker image | | `web/` | Source code for the `web` | diff --git a/docs/docs/developer/img/immich_mobile_architecture.drawio b/docs/docs/developer/img/immich_mobile_architecture.drawio new file mode 100644 index 0000000000..548cda0938 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.drawio @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/developer/img/immich_mobile_architecture.svg b/docs/docs/developer/img/immich_mobile_architecture.svg new file mode 100644 index 0000000000..71f28235bf --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.svg @@ -0,0 +1,3 @@ + + +
Mobile App
Mobile App
Services
Services
Repositories
Repositories
Providers
Providers
Pages
Pages
Widgets
Widgets
User
User
platform
system
platform...
on-device
database
on-device...
server
server
OpenAPI
OpenAPI
UI part
UI part
non-UI part
non-UI part
Models
Models
Entities
Entities
\ No newline at end of file diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 5f8d442aa8..32e79849ef 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -106,7 +106,7 @@ in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JS "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "editor.defaultFormatter": "Dart-Code.dart-code" } } diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index fecb58f592..ad64ba015c 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -4,7 +4,8 @@ ### Unit tests -Unit are run by calling `npm run test` from the `server` directory. +Unit are run by calling `npm run test` from the `server/` directory. +You need to run `npm install` (in `server/`) before _once_. ### End to end tests @@ -14,6 +15,11 @@ The e2e tests can be run by first starting up a test production environment via: make e2e ``` +Before you can run the tests, you need to run the following commands _once_: + +- `npm install` (in `e2e/`) +- `make open-api` (in the project root `/`) + Once the test environment is running, the e2e tests can be run via: ```bash diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index deba45cacc..4f059281f3 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -23,7 +23,7 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio - Raspberry Pi is currently not supported. - Two-pass mode is only supported for NVENC. Other APIs will ignore this setting. - By default, only encoding is currently hardware accelerated. This means the CPU is still used for software decoding and tone-mapping. - - NVENC and RKMPP can be fully accelerated by enabling hardware decoding in the video transcoding settings. + - You can benefit from end-to-end acceleration by enabling hardware decoding in the video transcoding settings. - Hardware dependent - Codec support varies, but H.264 and HEVC are usually supported. - Notably, NVIDIA and AMD GPUs do not support VP9 encoding. @@ -49,7 +49,7 @@ For RKMPP to work: - You must have a supported Rockchip ARM SoC. - Only RK3588 supports hardware tonemapping, other SoCs use slower software tonemapping while still using hardware encoding. -- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install [`libmali-valhall-g610-g6p0-gbm`][libmali-rockchip] and modify the [`hwaccel.transcoding.yml`][hw-file] file: +- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install the [`libmali`][libmali-rockchip] release that corresponds to your Mali GPU (`libmali-valhall-g610-g13p0-gbm` on RK3588) and modify the [`hwaccel.transcoding.yml`][hw-file] file: - under `rkmpp` uncomment the 3 lines required for OpenCL tonemapping by removing the `#` symbol at the beginning of each line - `- /dev/mali0:/dev/mali0` - `- /etc/OpenCL:/etc/OpenCL:ro` @@ -62,11 +62,14 @@ For RKMPP to work: 1. If you do not already have it, download the latest [`hwaccel.transcoding.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`. 2. In the `docker-compose.yml` under `immich-server`, uncomment the `extends` section and change `cpu` to the appropriate backend. -- For VAAPI on WSL2, be sure to use `vaapi-wsl` rather than `vaapi` + Note: For VAAPI on WSL2, be sure to use `vaapi-wsl` rather than `vaapi` 3. Redeploy the `immich-server` container with these updated settings. 4. In the Admin page under `Video transcoding settings`, change the hardware acceleration setting to the appropriate option and save. -5. (Optional) If using a compatible backend, you may enable hardware decoding for optimal performance. + + Note: For Jasper Lake and Elkhart Lake CPUs, you will need to set the `Hardware Acceleration` -> `Constant quality mode` to `CQP` + +5. (Optional) Enable hardware decoding for optimal performance. #### Single Compose File @@ -89,16 +92,7 @@ immich-server: devices: - /dev/dri:/dev/dri volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:3001 - depends_on: - - redis - - database - restart: always + ... ``` Once this is done, you can continue to step 3 of "Basic Setup". diff --git a/docs/docs/features/img/folder-view.png b/docs/docs/features/img/folder-view.png new file mode 100644 index 0000000000..8193b10ed9 Binary files /dev/null and b/docs/docs/features/img/folder-view.png differ diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index ffccb1286a..1d6028935f 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -1,18 +1,14 @@ -# Libraries +# External Libraries -## Overview +External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up. -Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. +If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost. -## External Libraries +:::caution -External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. +If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. -If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: - -- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets -- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. -- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. +::: :::caution @@ -20,22 +16,6 @@ Due to aggressive caching it can take some time for a refreshed asset to appear ::: -In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. - -:::caution - -If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. - -::: - -### Deleted External Assets - -Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work. - -In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. - -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. - ### Import Paths External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible. @@ -66,9 +46,13 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +Special characters such as @ should be escaped, for instance: + +- `**/\@eadir/**` will exclude all files in any directory named `@eadir` + ### Automatic watching (EXPERIMENTAL) -This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. +This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. @@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as 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. ## Usage @@ -104,22 +88,23 @@ The `immich-server` container will need access to the gallery. Modify your docke immich-server: volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/nas/christmas-trip:/mnt/nas/christmas-trip:ro -+ - /home/user/old-pics:/home/user/old-pics:ro ++ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro ++ - /home/user/old-pics:/mnt/media/old-pics:ro + - /mnt/media/videos:/mnt/media/videos:ro + - /mnt/media/videos2:/mnt/media/videos2 # the files in this folder can be deleted, as it does not end with :ro + - "C:/Users/user_name/Desktop/my media:/mnt/media/my-media:ro" # import path in Windows system. ``` :::tip -The `ro` flag at the end only gives read-only access to the volumes. This will disallow the images from being deleted in the web UI. +The `ro` flag at the end only gives read-only access to the volumes. +This will disallow the images from being deleted in the web UI, or adding metadata to the library ([XMP sidecars](/docs/features/xmp-sidecars)). ::: :::info _Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Create External Libraries +### Create A New Library These actions must be performed by the Immich administrator. @@ -143,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files. - Enter `**/Raw/**` and click save. - Click save - Click the drop-down menu on the newly created library -- Click on Scan Library Files +- Click on Scan The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. @@ -160,10 +145,26 @@ If you get an error here, please rename the other external library to something - Click on Add Path - Enter `/mnt/media/videos` then click Add - Click Save -- Click on Scan Library Files +- Click on Scan Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. +### Folder view + +:::info +This feature also exists for assets uploaded other than through external libraries. +:::tip +You can use the storage template migration feature for the best experience with uploaded assets in this view. +::: + +You can browse your photos and videos by folder like in a file explorer. + +Enable this feature from the Users Settings > Features > Folders. + +The UI is currently only available for the web; mobile will come in a subsequent release. + + + ### Set Custom Scan Interval :::note diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index b20c3fc2b6..ca1cb8edb1 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -38,7 +38,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - The GPU must have compute capability 5.2 or greater. - The server must have the official NVIDIA driver installed. -- The installed driver must be >= 545 (it must support CUDA 12.3.2). +- The installed driver must be >= 535 (it must support CUDA 12.2). - On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed. #### OpenVINO @@ -53,6 +53,12 @@ You do not need to redo any machine learning jobs after enabling hardware accele 3. Still in `immich-machine-learning`, add one of -[armnn, cuda, openvino] to the `image` section's tag at the end of the line. 4. Redeploy the `immich-machine-learning` container with these updated settings. +### Confirming Device Usage + +You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel and `intel_gpu_top` for Intel. + +You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN. + #### Single Compose File Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.ml.yml`][hw-file] file into the `immich-machine-learning` service directly. @@ -95,9 +101,22 @@ immich-machine-learning: Once this is done, you can redeploy the `immich-machine-learning` container. -:::info -You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `IMMICH_LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully. -::: +#### Multi-GPU + +If you want to utilize multiple NVIDIA or Intel GPUs, you can set the `MACHINE_LEARNING_DEVICE_IDS` environmental variable to a comma-separated list of device IDs and set `MACHINE_LEARNING_WORKERS` to the number of listed devices. You can run a command such as `nvidia-smi -L` or `glxinfo -B` to see the currently available devices and their corresponding IDs. + +For example, if you have devices 0 and 1, set the values as follows: + +``` +MACHINE_LEARNING_DEVICE_IDS=0,1 +MACHINE_LEARNING_WORKERS=2 +``` + +In this example, the machine learning service will spawn two workers, one of which will allocate models to device 0 and the other to device 1. Different requests will be processed by one worker or the other. + +This approach can be used to simply specify a particular device as well. For example, setting `MACHINE_LEARNING_DEVICE_IDS=1` will ensure device 1 is always used instead of device 0. + +Note that you should increase job concurrencies to increase overall utilization and more effectively distribute work across multiple GPUs. Additionally, each GPU must be able to load all models. It is not possible to distribute a single model to multiple GPUs that individually have insufficient VRAM, or to delegate a specific model to one GPU. [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml [nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html diff --git a/docs/docs/features/mobile-app.mdx b/docs/docs/features/mobile-app.mdx index 2019f8cbcf..11185fcaf1 100644 --- a/docs/docs/features/mobile-app.mdx +++ b/docs/docs/features/mobile-app.mdx @@ -27,3 +27,39 @@ The beta release channel allows users to test upcoming changes before they are o :::info You can enable automatic backup on supported devices. For more information see [Automatic Backup](/docs/features/automatic-backup.md). ::: + +## Album Sync + +You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically. + +### Album Synchronization Highlights + +- **One-Way Sync:** Synchronization is one-way, from the device to the server. + +- **Name Matching:** If an album on the server has the same name as the album on the device, images from the device will be merged with the existing images in the server album. + +- **Shared Albums:** If the matching album on the server is shared, the new photos merged into the album will also be shared. + +- **Album Structure:** When an album is created for the first time, its structure is based on the initial state. Future updates made on the phone (such as deleting or repositioning photos) will not be reflected in Immich. + +- **User-Specific Sync:** Album synchronization is unique to each server user and does not sync between different users or partners. + +- **Mobile-Only Feature:** Album synchronization is currently only available on mobile. For similar options on a computer, refer to [Libraries](/docs/features/libraries) for further details. + +### Synchronizing albums from the past + +Albums can be synchronized to the server even if they did not exist on the server before. In order to apply this setting you have to: +Enter the cloud on the top right -> cog wheel on the top right -> select the sync option under Sync albums. + +:::info Sync albums delete/move photos +If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums +It will only reflect files you add. +::: + +If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually. +To overcome this limitation, the files must be removed from the blacklist by +App settings -> Advanced -> Duplicate Assets -> Clear + +:::info +Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the black list again at the end of the synchronization. +::: diff --git a/docs/docs/features/monitoring.md b/docs/docs/features/monitoring.md index 9de3feb7f6..184394abd0 100644 --- a/docs/docs/features/monitoring.md +++ b/docs/docs/features/monitoring.md @@ -25,10 +25,10 @@ The metrics in immich are grouped into API (endpoint calls and response times), ### Configuration -Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_METRICS=true` environmental variable to your `.env` file. Note that only the server and microservices containers currently use this variable. +Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_TELEMETRY_INCLUDE=all` environmental variable to your `.env` file. Note that only the server container currently use this variable. :::tip -`IMMICH_METRICS` enables all metrics, but there are also [environmental variables](/docs/install/environment-variables.md#prometheus) to toggle specific metric groups. If you'd like to only expose certain kinds of metrics, you can set only those environmental variables to `true`. Explicitly setting the environmental variable for a metric group overrides `IMMICH_METRICS` for that group. For example, setting `IMMICH_METRICS=true` and `IMMICH_API_METRICS=false` will enable all metrics except API metrics. +`IMMICH_TELEMETRY_INCLUDE=all` enables all metrics. For a more granular configuration you can enumerate the telemetry metrics that should be included as a comma separated list (e.g. `IMMICH_TELEMETRY_INCLUDE=repo,api`). Alternatively, you can also exclude specific metrics with `IMMICH_TELEMETRY_EXCLUDE`. For more information refer to the [environment section](/docs/install/environment-variables.md#prometheus). ::: The next step is to configure a new or existing Prometheus instance to scrape this endpoint. The following steps assume that you do not have an existing Prometheus instance, but the steps will be similar either way. diff --git a/docs/docs/features/shared-albums.md b/docs/docs/features/shared-albums.md index d7995d284b..dcf884bc9b 100644 --- a/docs/docs/features/shared-albums.md +++ b/docs/docs/features/shared-albums.md @@ -16,7 +16,7 @@ When sharing shared albums, whats shared is: - Download all assets as zip file (Web only). :::info Archive size limited. - If the size of the album exceeds 4GB, the archive files will be divided into 4GB each. + If the size of the album exceeds 4GB, the archive files will by default be divided into 4GB each. This can be changed on the user settings page. ::: - Add a description to the album (Web only). - Slideshow view (Web only). @@ -73,14 +73,14 @@ You can edit the link properties, options include; - **Allow public user to download -** whether to allow whoever has the link to download all the images or a certain image (optional). - **Allow public user to upload -** whether to allow whoever has the link to upload assets to the album (optional). :::info - whoever has the link and have uploaded files cannot delete them. + Whoever has the link and have uploaded files cannot delete them. ::: - **Expire after -** adding an expiration date to the link (optional). ## Share Specific Assets A user can share specific assets without linking them to a specific album. -in order to do so; +In order to do this: 1. Go to the timeline 2. Select the assets (Shift can be used for multiple selection) @@ -152,7 +152,7 @@ Some of the features are not available on mobile, to understand what the full fe ## Sharing Between Users -#### Add or remove users from the album. +#### Add or remove users from the album :::info remove user(s) When a user is removed from the album, the photos he uploaded will still appear in the album. diff --git a/docs/docs/guides/custom-map-styles.md b/docs/docs/guides/custom-map-styles.md index 485daf1d40..9da9a34822 100644 --- a/docs/docs/guides/custom-map-styles.md +++ b/docs/docs/guides/custom-map-styles.md @@ -1,8 +1,22 @@ -# Create Custom Map Styles for Immich Using Maptiler +# Custom Map Styles -You may decide that you'd like to modify the style document which is used to draw the maps in Immich. This can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. +You may decide that you'd like to modify the style document which is used to +draw the maps in Immich. In addition to visual customization, this also allows +you to pick your own map tile provider instead of the default one. The default +`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json) +and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json) +can be used as a basis for creating your own style. -## Steps +There are several sources for already-made `style.json` map themes, as well as +online generators you can use. + +1. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection. +2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.) +3. Save your selections. Reload the map, and enjoy your custom map style! + +## Use Maptiler to build a custom style + +Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. 1. Create a free account at https://cloud.maptiler.com 2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there. @@ -11,6 +25,3 @@ You may decide that you'd like to modify the style document which is used to dra 5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.
![Maptiler Publication Settings](img/immich_map_styles_publish.png) 6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. 7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler. -8. In **Immich**, navigate to **Administration --> Settings --> Map & GPS Settings** and expand the **Map Settings** subsection. -9. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode. -10. Save your selections. Reload the map, and enjoy your custom map style! diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 20b841f402..2b4f27cfce 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -23,7 +23,7 @@ SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files wi ``` ```sql title="Find by path" -SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg'; +SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_2023.jpg'; SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%'; ``` @@ -37,6 +37,12 @@ SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e SELECT * FROM "assets" WHERE "checksum" = '\x69de19c87658c4c15d9cacb9967b8e033bf74dd1'; -- alternate notation ``` +```sql title="Find duplicate assets with identical checksum (SHA-1) (excluding trashed files)" +SELECT T1."checksum", array_agg(T2."id") ids FROM "assets" T1 + INNER JOIN "assets" T2 ON T1."checksum" = T2."checksum" AND T1."id" != T2."id" AND T2."deletedAt" IS NULL + WHERE T1."deletedAt" IS NULL GROUP BY T1."checksum"; +``` + ```sql title="Live photos" SELECT * FROM "assets" WHERE "livePhotoVideoId" IS NOT NULL; ``` @@ -79,8 +85,7 @@ SELECT "assets"."type", COUNT(*) FROM "assets" GROUP BY "assets"."type"; ```sql title="Count by type (per user)" SELECT "users"."email", "assets"."type", COUNT(*) FROM "assets" JOIN "users" ON "assets"."ownerId" = "users"."id" - GROUP BY "assets"."type", "users"."email" - ORDER BY "users"."email"; + GROUP BY "assets"."type", "users"."email" ORDER BY "users"."email"; ``` ```sql title="Failed file movements" diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index 07d1047ea0..b44949818c 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -7,7 +7,7 @@ in a directory on the same machine. # Mount the directory into the containers. Edit `docker-compose.yml` to add one or more new mount points in the section `immich-server:` under `volumes:`. -If you want Immich to be able to delete the images in the external library, remove `:ro` from the end of the mount point. +If you want Immich to be able to delete the images in the external library or add metadata ([XMP sidecars](/docs/features/xmp-sidecars)), remove `:ro` from the end of the mount point. ```diff immich-server: diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index cd8bf66f14..6f401dfc5a 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -11,13 +11,13 @@ Never forward port 2283 directly to the internet without additional configuratio You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/) -### Pros: +### Pros - Simple to set up and very secure. - Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk. - Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal. -### Cons: +### Cons - If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider. - VPN software needs to be installed and active on both server-side and client-side. @@ -27,6 +27,10 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). +:::tip Video tutorial +You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. +::: + ### Pros - Minimal configuration needed on server and client sides. @@ -44,7 +48,7 @@ A reverse proxy is a service that sits between web servers and clients. A revers If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md). -You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. +You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accessible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser. A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder. diff --git a/docs/docs/guides/remote-machine-learning.md b/docs/docs/guides/remote-machine-learning.md index e4186e1697..4dbb72a408 100644 --- a/docs/docs/guides/remote-machine-learning.md +++ b/docs/docs/guides/remote-machine-learning.md @@ -11,6 +11,10 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm Smart Search and Face Detection will use this feature, but Facial Recognition is handled in the server. ::: +:::danger +When using remote machine learning, the thumbnails are sent to the remote machine learning container. Use this option carefully when running this on a public computer or a paid processing cloud. +::: + ```yaml name: immich_remote_ml diff --git a/docs/docs/guides/scaling-immich.md b/docs/docs/guides/scaling-immich.md new file mode 100644 index 0000000000..a8d916ae2a --- /dev/null +++ b/docs/docs/guides/scaling-immich.md @@ -0,0 +1,19 @@ +# Scaling Immich + +Immich is built with modern deployment practices in mind, and the backend is designed to be able to run multiple instances in parallel. When doing this, the only requirement you need to be aware of is that every instance needs to be connected to the shared infrastructure. That means they should all have access to the same Postgres and Redis instances, and have the same files mounted into the containers. + +Scaling can be useful for many reasons. Maybe you have a gaming PC that you want to use for transcoding and thumbnail generation, or perhaps you run a Kubernetes cluster across a handful of powerful servers that you want to make use of. + +:::info +If you only have a single machine to run Immich on, scaling to multiple containers is unlikely to provide any benefit. An Immich container will run multiple background tasks at once, and you can increase their number from the admin panel. +::: + +The details of how to scale across multiple machines will vary widely between different environments and require some knowledge to set up, and as such this guide gives no specific instructions. In some cases scaling up can be as easy as incrementing the amount of replicas on a Kubernetes deployment, in others it might need you to configure network tunnels or NFS mounts. The details are left as an exercise for the reader ;) + +## Workers + +By default, each running `immich-server` container comes with multiple internal workers. If you're scaling up only to handle more background tasks, you can choose to disable the worker responsible for the API. See [workers](../administration/jobs-workers.md) for more detail. + +## Scaling down + +In the same way you can scale up to multiple containers, you can also choose to scale down. All state is stored in Postgres, Redis, and the filesystem so there is no risk in stopping a running immich-server container, for example if you want to use your GPU to play some games. As long as there is an API worker running you will still be able to browse Immich, and jobs will wait to be processed until there is a worker available for them. diff --git a/docs/docs/guides/template-backup-script.md b/docs/docs/guides/template-backup-script.md index 9777d00262..a0cd890a49 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -9,7 +9,7 @@ The database is saved to your Immich upload folder in the `database-backup` subd ### Prerequisites - Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html). -- (Optional) To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). +- (Optional) To run this script as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). - To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server. If you skipped the previous step, make sure this step is done from your root account. To initialize the borg repository, run the following commands once. @@ -78,4 +78,4 @@ borg mount "$REMOTE_HOST:$REMOTE_BACKUP_PATH"/immich-borg /tmp/immich-mountpoint cd /tmp/immich-mountpoint ``` -You can find available snapshots in seperate sub-directories at `/tmp/immich-mountpoint`. Restore the files you need, and unmount the Borg repository using `borg umount /tmp/immich-mountpoint` +You can find available snapshots in separate sub-directories at `/tmp/immich-mountpoint`. Restore the files you need, and unmount the Borg repository using `borg umount /tmp/immich-mountpoint` diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b3..ed902f39cf 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -19,7 +19,8 @@ The default configuration looks like this: "targetVideoCodec": "h264", "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", - "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 9045891fd8..b73d51b4d2 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -56,7 +56,9 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate custom database information if necessary. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. -- Consider changing `DB_PASSWORD` to something randomly generated +- Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically 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`. +- Set your timezone by uncommenting the `TZ=` line. ### Step 3 - Start the containers @@ -79,6 +81,10 @@ The Compose file './docker-compose.yml' is invalid because: See the previous paragraph about installing from the official docker repository. ::: +:::info Health check start interval +If you get an error `can't set healthcheck.start_interval as feature require Docker Engine v25 or later`, it helps to comment out the line for `start_interval` in the `database` section of the `docker-compose.yml` file. +::: + :::tip For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide. ::: @@ -108,7 +114,7 @@ Immich is currently under heavy development, which means you can expect [breakin [compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml [env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [watchtower]: https://containrrr.dev/watchtower/ -[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Abreaking-change+sort%3Adate_created +[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry [releases]: https://github.com/immich-app/immich/releases [docker-repo]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 5cbef2cbf1..1f34b5c6d0 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -27,39 +27,30 @@ If this should not work, try running `docker compose up -d --force-recreate`. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. ::: -### Supported filesystems - -The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group -ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. -It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). -If this is an issue, you can change the bind mount to a Docker volume instead. - -Regardless of filesystem, it is not recommended to use a network share for your database location due to performance and possible data loss issues. - ## General -| Variable | Description | Default | Containers | Workers | -| :---------------------------------- | :-------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | | 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 | `./upload`\*1 | 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 | | -| `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 | - -\*1: 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. - -:::tip -`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. +| Variable | Description | Default | Containers | Workers | +| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | +| `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_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 | | +| `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_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"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -::: + +\*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. ## Workers @@ -77,7 +68,7 @@ Information on the current workers can be found [here](/docs/administration/jobs | Variable | Description | Default | | :------------ | :------------- | :----------------------------------------: | | `IMMICH_HOST` | Listening host | `0.0.0.0` | -| `IMMICH_PORT` | Listening port | `3001` (server), `3003` (machine learning) | +| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | ## Database @@ -123,7 +114,7 @@ When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSW 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][redis-api] documentation. +More info 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. ::: @@ -157,26 +148,33 @@ Redis (Sentinel) URL example JSON before encoding: ## Machine Learning -| Variable | Description | Default | Containers | -| :----------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------------: | :--------------- | -| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | -| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | -| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | -| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | -| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | -| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | -| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | -| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO image) | machine learning | -| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | -| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | -| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | -| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| Variable | Description | Default | Containers | +| :-------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- | +| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning | +| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning | +| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | +| `MACHINE_LEARNING_REQUEST_THREADS`\*1 | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning | +| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | +| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | +| `MACHINE_LEARNING_WORKERS`\*2 | Number of worker processes to spawn | `1` | machine learning | +| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`\*3 | HTTP Keep-alive time in seconds | `2` | machine learning | +| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning | +| `MACHINE_LEARNING_PRELOAD__CLIP` | Name of a CLIP model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION` | Name of a facial recognition model to be preloaded and kept in cache | | machine learning | +| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | +| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | +| `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 | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around. +\*3: For scenarios like HPA in K8S. https://github.com/immich-app/immich/discussions/12064 + +\*4: Using multiple GPUs requires `MACHINE_LEARNING_WORKERS` to be set greater than 1. A single device is assigned to each worker in round-robin priority. + :::info Other machine learning parameters can be tuned from the admin UI. @@ -185,15 +183,10 @@ Other machine learning parameters can be tuned from the admin UI. ## Prometheus -| Variable | Description | Default | Containers | Workers | -| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- | -| `IMMICH_METRICS`\*1 | Toggle all metrics (one of [`true`, `false`]) | | server | api, microservices | -| `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server | api, microservices | -| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server | api, microservices | -| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server | api, microservices | -| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server | api, microservices | - -\*1: Overridden for a metric group when its corresponding environmental variable is set. +| Variable | Description | Default | Containers | Workers | +| :------------------------- | :-------------------------------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- | +| `IMMICH_TELEMETRY_INCLUDE` | Collect these telemetries. List of `host`, `api`, `io`, `repo`, `job`. Note: You can also specify `all` to enable all | | server | api, microservices | +| `IMMICH_TELEMETRY_EXCLUDE` | Do not collect these telemetries. List of `host`, `api`, `io`, `repo`, `job` | | server | api, microservices | ## Docker Secrets @@ -221,4 +214,4 @@ to use use a Docker secret for the password in the Redis container. [docker-secrets-example]: https://github.com/docker-library/redis/issues/46#issuecomment-335326234 [docker-secrets-docs]: https://github.com/docker-library/docs/tree/master/postgres#docker-secrets [docker-secrets]: https://docs.docker.com/engine/swarm/secrets/ -[redis-api]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository +[ioredis]: https://ioredis.readthedocs.io/en/latest/README/#connect-to-redis diff --git a/docs/docs/install/post-install.mdx b/docs/docs/install/post-install.mdx index ba8aa2e9a3..bc1ee80b47 100644 --- a/docs/docs/install/post-install.mdx +++ b/docs/docs/install/post-install.mdx @@ -8,6 +8,7 @@ import StorageTemplate from '/docs/partials/_storage-template.md'; import MobileAppDownload from '/docs/partials/_mobile-app-download.md'; import MobileAppLogin from '/docs/partials/_mobile-app-login.md'; import MobileAppBackup from '/docs/partials/_mobile-app-backup.md'; +import ServerBackup from '/docs/partials/_server-backup.md'; # Post Install Steps @@ -33,6 +34,10 @@ A list of common steps to take after installing Immich include: -## Step 6 - Backup Your Library +## Step 6 - Upload Your Library + +## Step 7 - Setup Server Backups + + diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 8944336ec7..b96705203a 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -4,7 +4,7 @@ sidebar_position: 10 # Requirements -Hardware and software requirements for Immich +Hardware and software requirements for Immich: ## Software @@ -23,7 +23,33 @@ Immich requires the command `docker compose` - the similarly named `docker-compo - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - - This can present an issue for Windows users. See [here](/docs/install/environment-variables#supported-filesystems) - for more details and alternatives. + - This can present an issue for Windows users. See below for details and an alternative setup. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - - Network shares are supported for the storage of image and video assets only. + - Network shares are supported for the storage of image and video assets only. It is not recommended to use a network share for your database location due to performance and possible data loss issues. + +### Special requirements for Windows users + +
+Database storage on Windows systems + +The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group +ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. +It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). +If this is an issue, you can change the bind mount to a Docker volume instead as follows: + +Make the following change to `.env`: + +```diff +- DB_DATA_LOCATION=./postgres ++ DB_DATA_LOCATION=pgdata +``` + +Add the following line to the bottom of `docker-compose.yml`: + +```diff +volumes: + model-cache: ++ pgdata: +``` + +
diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index 271cd52cab..ffb559ed12 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -30,6 +30,8 @@ You can organize these as one parent with seven child datasets, for example `mnt :::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. + +The **library** dataset 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 **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. ::: ## Installing the Immich Application diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index 67de980186..356f81c9e8 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -45,7 +45,7 @@ width="70%" alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" /> -3. Select the cog ⚙️ next to Immich then click "**Edit Stack**" +3. Select the cogwheel ⚙️ next to Immich and click "**Edit Stack**" 4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed.
@@ -77,6 +77,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" 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. very active development. Expect bugs and changes. Do not use it as the only way to store your photos and videos!`, - backgroundColor: '#593f00', - textColor: '#ffefc9', isCloseable: false, }, docs: { @@ -145,28 +140,36 @@ const config = { label: 'Installation', to: '/docs/install/requirements', }, + { + label: 'Contributing', + to: '/docs/overview/support-the-project', + }, + { + label: 'Privacy Policy', + to: '/privacy-policy', + }, ], }, { - title: 'Community', + title: 'Documentation', items: [ { - label: 'Discord', - href: 'https://discord.immich.app', + label: 'Roadmap', + to: '/roadmap', }, { - label: 'Reddit', - href: 'https://www.reddit.com/r/immich/', + label: 'API', + to: '/docs/api', + }, + { + label: 'Cursed Knowledge', + to: '/cursed-knowledge', }, ], }, { title: 'Links', items: [ - // { - // label: "Blog", - // to: "/blog", - // }, { label: 'GitHub', href: 'https://github.com/immich-app/immich', @@ -175,6 +178,14 @@ const config = { label: 'YouTube', href: 'https://www.youtube.com/@immich-app', }, + { + label: 'Discord', + href: 'https://discord.immich.app', + }, + { + label: 'Reddit', + href: 'https://www.reddit.com/r/immich/', + }, ], }, ], @@ -185,7 +196,7 @@ const config = { darkTheme: prism.themes.dracula, additionalLanguages: ['sql', 'diff', 'bash', 'powershell', 'nginx'], }, - image: 'overview/img/feature-panel.png', + image: 'img/feature-panel.png', }), }; diff --git a/docs/package-lock.json b/docs/package-lock.json index bb83c65b25..38e376c7e6 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -2155,9 +2155,9 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.4.0.tgz", - "integrity": "sha512-g+0wwmN2UJsBqy2fQRQ6fhXruoEa62JDeEa5d8IdTJlMoaDaEDfHh7WjwGRn4opuTQWpjAwP/fbcgyHKlE+64w==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.5.2.tgz", + "integrity": "sha512-4Z1WkhCSkX4KO0Fw5m/Vuc7Q3NxBG53NE5u59Rs96fWkMPZVSrzEPP16/Nk6cWb/shK7xXPndTmalJtw7twL/w==", "license": "MIT", "dependencies": { "@babel/core": "^7.23.3", @@ -2170,12 +2170,12 @@ "@babel/runtime": "^7.22.6", "@babel/runtime-corejs3": "^7.22.6", "@babel/traverse": "^7.22.8", - "@docusaurus/cssnano-preset": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/cssnano-preset": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "autoprefixer": "^10.4.14", "babel-loader": "^9.1.3", "babel-plugin-dynamic-import-node": "^2.3.3", @@ -2236,14 +2236,15 @@ "node": ">=18.0" }, "peerDependencies": { + "@mdx-js/react": "^3.0.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.4.0.tgz", - "integrity": "sha512-qwLFSz6v/pZHy/UP32IrprmH5ORce86BGtN0eBtG75PpzQJAzp9gefspox+s8IEOr0oZKuQ/nhzZ3xwyc3jYJQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.5.2.tgz", + "integrity": "sha512-D3KiQXOMA8+O0tqORBrTOEQyQxNIfPm9jEaJoALjjSjc2M/ZAWcUfPQEnwr2JB2TadHw2gqWgpZckQmrVWkytA==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -2256,9 +2257,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.4.0.tgz", - "integrity": "sha512-bZwkX+9SJ8lB9kVRkXw+xvHYSMGG4bpYHKGXeXFvyVc79NMeeBSGgzd4TQLHH+DYeOJoCdl8flrFJVxlZ0wo/Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.5.2.tgz", + "integrity": "sha512-LHC540SGkeLfyT3RHK3gAMK6aS5TRqOD4R72BEU/DE2M/TY8WwEUAMY576UUc/oNJXv8pGhBmQB6N9p3pt8LQw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -2269,14 +2270,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.4.0.tgz", - "integrity": "sha512-kSSbrrk4nTjf4d+wtBA9H+FGauf2gCax89kV8SUSJu3qaTdSIKdWERlngsiHaCFgZ7laTJ8a67UFf+xlFPtuTw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.5.2.tgz", + "integrity": "sha512-ku3xO9vZdwpiMIVd8BzWV0DCqGEbCP5zs1iHfKX50vw6jX8vQo0ylYo1YJMZyz6e+JFJ17HYHT5FzVidz2IflA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -2308,12 +2309,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.4.0.tgz", - "integrity": "sha512-A1AyS8WF5Bkjnb8s+guTDuYmUiwJzNrtchebBHpc0gz0PyHJNMaybUlSrmJjHVcGrya0LKI4YcR3lBDQfXRYLw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.5.2.tgz", + "integrity": "sha512-Z+Xu3+2rvKef/YKTMxZHsEXp1y92ac0ngjDiExRdqGTmEKtCUpkbNYH8v5eXo5Ls+dnW88n6WTa+Q54kLOkwPg==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.4.0", + "@docusaurus/types": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2326,52 +2327,21 @@ "react-dom": "*" } }, - "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.4.0.tgz", - "integrity": "sha512-vv6ZAj78ibR5Jh7XBUT4ndIjmlAxkijM3Sx5MAAzC1gyv0vupDQNhzuFg1USQmQVj3P5I6bquk12etPV3LJ+Xw==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "cheerio": "^1.0.0-rc.12", - "feed": "^4.2.2", - "fs-extra": "^11.1.1", - "lodash": "^4.17.21", - "reading-time": "^1.5.0", - "srcset": "^4.0.0", - "tslib": "^2.6.0", - "unist-util-visit": "^5.0.0", - "utility-types": "^3.10.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.4.0.tgz", - "integrity": "sha512-HkUCZffhBo7ocYheD9oZvMcDloRnGhBMOZRyVcAQRFmZPmNqSyISlXA1tQCIxW+r478fty97XXAGjNYzBjpCsg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.5.2.tgz", + "integrity": "sha512-Bt+OXn/CPtVqM3Di44vHjE7rPCEsRCB/DMo2qoOuozB9f7+lsdrHvD0QCHdBs0uhz6deYJDppAr2VgqybKPlVQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -2389,38 +2359,15 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.4.0.tgz", - "integrity": "sha512-h2+VN/0JjpR8fIkDEAoadNjfR3oLzB+v1qSXbIAKjQ46JAHx3X22n9nqS+BWSQnTnp1AjkjSvZyJMekmcwxzxg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", - "fs-extra": "^11.1.1", - "tslib": "^2.6.0", - "webpack": "^5.88.1" - }, - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.4.0.tgz", - "integrity": "sha512-uV7FDUNXGyDSD3PwUaf5YijX91T5/H9SX4ErEcshzwgzWwBtK37nUWPU3ZLJfeTavX3fycTOqk9TglpOLaWkCg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.5.2.tgz", + "integrity": "sha512-kBK6GlN0itCkrmHuCS6aX1wmoWc5wpd5KJlqQ1FyrF0cLDnvsYSnh7+ftdwzt7G6lGBho8lrVwkkL9/iQvaSOA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^1.2.0", "tslib": "^2.6.0" @@ -2434,14 +2381,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.4.0.tgz", - "integrity": "sha512-mCArluxEGi3cmYHqsgpGGt3IyLCrFBxPsxNZ56Mpur0xSlInnIHoeLDH7FvVVcPJRPSQ9/MfRqLsainRw+BojA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.5.2.tgz", + "integrity": "sha512-rjEkJH/tJ8OXRE9bwhV2mb/WP93V441rD6XnM6MIluu7rk8qg38iSxS43ga2V2Q/2ib53PcqbDEJDG/yWQRJhQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2453,14 +2400,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.4.0.tgz", - "integrity": "sha512-Dsgg6PLAqzZw5wZ4QjUYc8Z2KqJqXxHxq3vIoyoBWiLEEfigIs7wHR+oiWUQy3Zk9MIk6JTYj7tMoQU0Jm3nqA==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.5.2.tgz", + "integrity": "sha512-lm8XL3xLkTPHFKKjLjEEAHUrW0SZBSHBE1I+i/tmYMBsjCcUB5UJ52geS5PSiOCFVR74tbPGcPHEV/gaaxFeSA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@types/gtag.js": "^0.0.12", "tslib": "^2.6.0" }, @@ -2473,14 +2420,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.4.0.tgz", - "integrity": "sha512-O9tX1BTwxIhgXpOLpFDueYA9DWk69WCbDRrjYoMQtFHSkTyE7RhNgyjSPREUWJb9i+YUg3OrsvrBYRl64FCPCQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.5.2.tgz", + "integrity": "sha512-QkpX68PMOMu10Mvgvr5CfZAzZQFx8WLlOiUQ/Qmmcl6mjGK6H21WLT5x7xDmcpCoKA/3CegsqIqBR+nA137lQg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "tslib": "^2.6.0" }, "engines": { @@ -2492,17 +2439,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.4.0.tgz", - "integrity": "sha512-+0VDvx9SmNrFNgwPoeoCha+tRoAjopwT0+pYO1xAbyLcewXSemq+eLxEa46Q1/aoOaJQ0qqHELuQM7iS2gp33Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.5.2.tgz", + "integrity": "sha512-DnlqYyRAdQ4NHY28TfHuVk414ft2uruP4QWCH//jzpHjqvKyXjj2fmDtI8RPUBh9K8iZKFMHRnLtzJKySPWvFA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -2516,24 +2463,81 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.4.0.tgz", - "integrity": "sha512-Ohj6KB7siKqZaQhNJVMBBUzT3Nnp6eTKqO+FXO3qu/n1hJl3YLwVKTWBg28LF7MWrKu46UuYavwMRxud0VyqHg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.5.2.tgz", + "integrity": "sha512-3ihfXQ95aOHiLB5uCu+9PRy2gZCeSZoDcqpnDvf3B+sTrMvMTr8qRUzBvWkoIqc82yG5prCboRjk1SVILKx6sg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/plugin-debug": "3.4.0", - "@docusaurus/plugin-google-analytics": "3.4.0", - "@docusaurus/plugin-google-gtag": "3.4.0", - "@docusaurus/plugin-google-tag-manager": "3.4.0", - "@docusaurus/plugin-sitemap": "3.4.0", - "@docusaurus/theme-classic": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-search-algolia": "3.4.0", - "@docusaurus/types": "3.4.0" + "@docusaurus/core": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/plugin-debug": "3.5.2", + "@docusaurus/plugin-google-analytics": "3.5.2", + "@docusaurus/plugin-google-gtag": "3.5.2", + "@docusaurus/plugin-google-tag-manager": "3.5.2", + "@docusaurus/plugin-sitemap": "3.5.2", + "@docusaurus/theme-classic": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-search-algolia": "3.5.2", + "@docusaurus/types": "3.5.2" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" }, "engines": { "node": ">=18.0" @@ -2544,27 +2548,27 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.4.0.tgz", - "integrity": "sha512-0IPtmxsBYv2adr1GnZRdMkEQt1YW6tpzrUPj02YxNpvJ5+ju4E13J5tB4nfdaen/tfR1hmpSPlTFPvTf4kwy8Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.5.2.tgz", + "integrity": "sha512-XRpinSix3NBv95Rk7xeMF9k4safMkwnpSgThn0UNQNumKvmcIYjfkwfh2BhwYh/BxMXQHJ/PdmNh22TQFpIaYg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.4.0", - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/types": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/plugin-content-blog": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/plugin-content-pages": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", - "infima": "0.2.0-alpha.43", + "infima": "0.2.0-alpha.44", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.4.26", @@ -2583,19 +2587,73 @@ "react-dom": "^18.0.0" } }, - "node_modules/@docusaurus/theme-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.4.0.tgz", - "integrity": "sha512-0A27alXuv7ZdCg28oPE8nH/Iz73/IUejVaCazqu9elS4ypjiLhK3KfzdSQBnL/g7YfHSlymZKdiOHEo8fJ0qMA==", + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-blog": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.5.2.tgz", + "integrity": "sha512-R7ghWnMvjSf+aeNDH0K4fjyQnt5L0KzUEnUhmf1e3jZrv3wogeytZNN6n7X8yHcMsuZHPOrctQhXWnmxu+IRRg==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.4.0", - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/plugin-content-blog": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/plugin-content-pages": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "cheerio": "1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-classic/node_modules/@docusaurus/plugin-content-pages": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.5.2.tgz", + "integrity": "sha512-WzhHjNpoQAUz/ueO10cnundRz+VUtkjFhhaQ9jApyv1a46FPURO4cef89pyNIOMny1fjDz/NUN2z6Yi+5WUrCw==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.5.2", + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/types": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.5.2.tgz", + "integrity": "sha512-QXqlm9S6x9Ibwjs7I2yEDgsCocp708DrCrgHgKwg2n2AY0YQ6IjU0gAK35lHRLOvAoJUfCKpQAwUykB0R7+Eew==", + "license": "MIT", + "dependencies": { + "@docusaurus/mdx-loader": "3.5.2", + "@docusaurus/module-type-aliases": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2609,24 +2667,25 @@ "node": ">=18.0" }, "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.4.0.tgz", - "integrity": "sha512-aiHFx7OCw4Wck1z6IoShVdUWIjntC8FHCw9c5dR8r3q4Ynh+zkS8y2eFFunN/DL6RXPzpnvKCg3vhLQYJDmT9Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.5.2.tgz", + "integrity": "sha512-qW53kp3VzMnEqZGjakaV90sst3iN1o32PH+nawv1uepROO8aEGxptcq2R5rsv7aBShSRbZwIobdvSYKsZ5pqvA==", "license": "MIT", "dependencies": { "@docsearch/react": "^3.5.2", - "@docusaurus/core": "3.4.0", - "@docusaurus/logger": "3.4.0", - "@docusaurus/plugin-content-docs": "3.4.0", - "@docusaurus/theme-common": "3.4.0", - "@docusaurus/theme-translations": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-validation": "3.4.0", + "@docusaurus/core": "3.5.2", + "@docusaurus/logger": "3.5.2", + "@docusaurus/plugin-content-docs": "3.5.2", + "@docusaurus/theme-common": "3.5.2", + "@docusaurus/theme-translations": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-validation": "3.5.2", "algoliasearch": "^4.18.0", "algoliasearch-helper": "^3.13.3", "clsx": "^2.0.0", @@ -2645,9 +2704,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.4.0.tgz", - "integrity": "sha512-zSxCSpmQCCdQU5Q4CnX/ID8CSUUI3fvmq4hU/GNP/XoAWtXo9SAVnM3TzpU8Gb//H3WCsT8mJcTfyOk3d9ftNg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.5.2.tgz", + "integrity": "sha512-GPZLcu4aT1EmqSTmbdpVrDENGR2yObFEX8ssEFYTCiAIVc0EihNSdOIBTazUvgNqwvnoU1A8vIs1xyzc3LITTw==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -2658,9 +2717,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.4.0.tgz", - "integrity": "sha512-4jcDO8kXi5Cf9TcyikB/yKmz14f2RZ2qTRerbHAsS+5InE9ZgSLBNLsewtFTcTOXSVcbU3FoGOzcNWAmU1TR0A==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.5.2.tgz", + "integrity": "sha512-N6GntLXoLVUwkZw7zCxwy9QiuEXIcTVzA9AkmNw16oc0AP3SXLrMmDMMBIfgqwuKWa6Ox6epHol9kMtJqekACw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -2679,13 +2738,13 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.4.0.tgz", - "integrity": "sha512-fRwnu3L3nnWaXOgs88BVBmG1yGjcQqZNHG+vInhEa2Sz2oQB+ZjbEMO5Rh9ePFpZ0YDiDUhpaVjwmS+AU2F14g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.5.2.tgz", + "integrity": "sha512-33QvcNFh+Gv+C2dP9Y9xWEzMgf3JzrpL2nW9PopidiohS1nDcyknKRx2DWaFvyVTTYIkkABVSr073VTj/NITNA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "@svgr/webpack": "^8.1.0", "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", @@ -2718,9 +2777,9 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.4.0.tgz", - "integrity": "sha512-NVx54Wr4rCEKsjOH5QEVvxIqVvm+9kh7q8aYTU5WzUU9/Hctd6aTrcZ3G0Id4zYJ+AeaG5K5qHA4CY5Kcm2iyQ==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.5.2.tgz", + "integrity": "sha512-i0AZjHiRgJU6d7faQngIhuHKNrszpL/SHQPgF1zH4H+Ij6E9NBYGy6pkcGWToIv7IVPbs+pQLh1P3whn0gWXVg==", "license": "MIT", "dependencies": { "tslib": "^2.6.0" @@ -2738,14 +2797,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.4.0.tgz", - "integrity": "sha512-hYQ9fM+AXYVTWxJOT1EuNaRnrR2WGpRdLDQG07O8UOpsvCPWUVOeo26Rbm0JWY2sGLfzAb+tvJ62yF+8F+TV0g==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.5.2.tgz", + "integrity": "sha512-m+Foq7augzXqB6HufdS139PFxDC5d5q2QKZy8q0qYYvGdI6nnlNsGH4cIGsgBnV7smz+mopl3g4asbSDvMV0jA==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.4.0", - "@docusaurus/utils": "3.4.0", - "@docusaurus/utils-common": "3.4.0", + "@docusaurus/logger": "3.5.2", + "@docusaurus/utils": "3.5.2", + "@docusaurus/utils-common": "3.5.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -2947,9 +3006,10 @@ } }, "node_modules/@mdx-js/react": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", - "integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", + "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", + "license": "MIT", "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4237,9 +4297,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "funding": [ { "type": "opencollective", @@ -4254,12 +4314,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -4531,9 +4592,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "funding": [ { "type": "opencollective", @@ -4548,11 +4609,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -4699,9 +4761,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001614", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz", - "integrity": "sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "funding": [ { "type": "opencollective", @@ -4715,7 +4777,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/ccount": { "version": "2.0.1", @@ -6006,9 +6069,10 @@ } }, "node_modules/docusaurus-lunr-search": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.4.0.tgz", - "integrity": "sha512-GfllnNXCLgTSPH9TAKWmbn8VMfwpdOAZ1xl3T2GgX8Pm26qSDLfrrdVwjguaLfMJfzciFL97RKrAJlgrFM48yw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.5.0.tgz", + "integrity": "sha512-k3zN4jYMi/prWInJILGKOxE+BVcgYinwj9+gcECsYm52tS+4ZKzXQzbPnVJAEXmvKOfFMcDFvS3MSmm6cEaxIQ==", + "license": "MIT", "dependencies": { "autocomplete.js": "^0.37.0", "clsx": "^1.2.1", @@ -6035,14 +6099,16 @@ } }, "node_modules/docusaurus-lunr-search/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" }, "node_modules/docusaurus-lunr-search/node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -6052,6 +6118,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", "engines": { "node": ">=6" } @@ -6060,6 +6127,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", "engines": { "node": ">=8" } @@ -6068,6 +6136,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -6077,6 +6146,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "license": "MIT", "dependencies": { "bail": "^1.0.0", "extend": "^3.0.0", @@ -6094,6 +6164,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.2" }, @@ -6106,6 +6177,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "is-buffer": "^2.0.0", @@ -6121,6 +6193,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^2.0.0" @@ -6342,9 +6415,10 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.751", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.751.tgz", - "integrity": "sha512-2DEPi++qa89SMGRhufWTiLmzqyuGmNF3SK4+PQetW1JKiZdEpF4XQonJXJCzyuYSA6mauiMhbyVhqYAP45Hvfw==" + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -8763,9 +8837,10 @@ } }, "node_modules/infima": { - "version": "0.2.0-alpha.43", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", - "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", + "version": "0.2.0-alpha.44", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.44.tgz", + "integrity": "sha512-tuRkUSO/lB3rEhLJk25atwAjgLuzq070+pOW8XcvpHky/YbENnRRdPd85IBkyeTgttmOy5ah+yHYsK1HhUd4lQ==", + "license": "MIT", "engines": { "node": ">=12" } @@ -11958,9 +12033,10 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "license": "MIT" }, "node_modules/nopt": { "version": "1.0.10", @@ -12640,9 +12716,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "license": "ISC" }, "node_modules/picomatch": { @@ -12754,9 +12830,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -12774,8 +12850,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -13633,9 +13709,10 @@ } }, "node_modules/prism-react-renderer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz", - "integrity": "sha512-Rdf+HzBLR7KYjzpJ1rSoxT9ioO85nZngQEoFIhL07XhtJHlCU3SOz0GJ6+qvMyQe0Se+BV3qpe6Yd/NmQF5Juw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.0.tgz", + "integrity": "sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==", + "license": "MIT", "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" @@ -13747,9 +13824,10 @@ } }, "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.6" }, @@ -15593,9 +15671,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -16014,9 +16093,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", - "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -16376,9 +16455,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -16607,9 +16686,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -16624,9 +16703,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -16714,12 +16794,16 @@ } }, "node_modules/url": { - "version": "0.11.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz", - "integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==", + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", "dependencies": { "punycode": "^1.4.1", - "qs": "^6.11.2" + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/url-loader": { @@ -16783,7 +16867,8 @@ "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" }, "node_modules/util": { "version": "0.10.4", diff --git a/docs/package.json b/docs/package.json index e32fe09499..a102c6d0d5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -56,6 +56,6 @@ "node": ">=20" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 1c1ad7cabd..7f4206c97b 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -43,24 +43,29 @@ const guides: CommunityGuidesProps[] = [ description: 'Access your local Immich installation over the internet using your own domain', url: 'https://github.com/ppr88/immich-guides/blob/main/open-immich-custom-domain.md', }, + { + title: 'Nginx caching map server', + description: 'Increase privacy by using nginx as a caching proxy in front of a map tile server', + url: 'https://github.com/pcouy/pcouy.github.io/blob/main/_posts/2024-08-30-proxying-a-map-tile-server-for-increased-privacy.md', + }, ]; function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { return ( -
+
-

+

{title}

{description}

-

+

{url}

View Guide diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index 9b602f4e08..3a034e3a04 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -28,11 +28,6 @@ const projects: CommunityProjectProps[] = [ description: 'A simple way to remove orphaned offline assets from the Immich database', url: 'https://github.com/Thoroslives/immich_remove_offline_files', }, - { - title: 'Create albums from folders', - description: 'A Python script to create albums based on the folder structure of an external library.', - url: 'https://github.com/Salvoxia/immich-folder-album-creator', - }, { title: 'Immich-Tools', description: 'Provides scripts for handling problems on the repair page.', @@ -43,6 +38,11 @@ const projects: CommunityProjectProps[] = [ description: 'Lightroom plugin to publish photos from Lightroom collections to Immich albums.', url: 'https://github.com/midzelis/mi.Immich.Publisher', }, + { + title: 'Lightroom Immich Plugin: lrc-immich-plugin', + description: 'Another Lightroom plugin to publish or export photos from Lightroom to Immich.', + url: 'https://github.com/bmachek/lrc-immich-plugin', + }, { title: 'Immich Duplicate Finder', description: 'Webapp that uses machine learning to identify near-duplicate images.', @@ -58,32 +58,52 @@ const projects: CommunityProjectProps[] = [ description: 'Unofficial Immich Android TV app.', url: 'https://github.com/giejay/Immich-Android-TV', }, + { + title: 'Create albums from folders', + description: 'A Python script to create albums based on the folder structure of an external library.', + url: 'https://github.com/Salvoxia/immich-folder-album-creator', + }, { title: 'Powershell Module PSImmich', description: 'Powershell Module for the Immich API', url: 'https://github.com/hanpq/PSImmich', }, + { + title: 'Immich Distribution', + description: 'Snap package for easy install and zero-care auto updates of Immich. Self-hosted photo management.', + url: 'https://immich-distribution.nsg.cc', + }, + { + title: 'Immich Kiosk', + description: 'Lightweight slideshow to run on kiosk devices and browsers.', + url: 'https://github.com/damongolding/immich-kiosk', + }, + { + title: 'Immich Power Tools', + description: 'Power tools for organizing your immich library.', + url: 'https://github.com/varun-raj/immich-power-tools', + }, ]; function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { return ( -
+
-

+

{title}

{description}

-

+

{url}

- View Project + View Link
diff --git a/docs/src/components/svg-paths.ts b/docs/src/components/svg-paths.ts new file mode 100644 index 0000000000..112ed1d70f --- /dev/null +++ b/docs/src/components/svg-paths.ts @@ -0,0 +1,2 @@ +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'; diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index dae822f4f7..b89a65c6e4 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -1,4 +1,3 @@ -import '@docusaurus/theme-classic/lib/theme/Unlisted/index'; import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 5ee7bf7393..f693ce701b 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -7,11 +7,12 @@ @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); html, button { - font-family: 'Overpass', sans-serif; + font-family: 'Be Vietnam Pro', sans-serif; + font-optical-sizing: auto; } img { @@ -27,7 +28,6 @@ img { --ifm-color-primary-light: #4250af; --ifm-color-primary-lighter: #4250af; --ifm-color-primary-lightest: #4250af; - --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } @@ -40,10 +40,28 @@ img { --ifm-color-primary-light: #d5e4fc; --ifm-color-primary-lighter: #e9f1fe; --ifm-color-primary-lightest: #ffffff; - --ifm-background-color: #000000; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); + --ifm-background-color: #000000; } div[class^='announcementBar_'] { min-height: 2rem; + background-color: #2b3336; + color: white; +} + +.menu__link { + padding: 10px; + padding-left: 16px; + border-radius: 10px; + font-size: 15px; +} + +.menu__list-item-collapsible { + border-radius: 10px; + font-size: 15px; +} + +code { + font-weight: 600; } diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index ade68161ba..1e5c724d16 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -1,13 +1,74 @@ -import { mdiCalendarToday, mdiLeadPencil, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; +import { + mdiBug, + mdiCalendarToday, + mdiCrosshairsOff, + mdiDatabase, + mdiLeadPencil, + mdiLockOff, + mdiLockOutline, + mdiMicrosoftWindows, + mdiSecurity, + mdiSpeedometerSlow, + mdiTrashCan, + mdiWeb, + mdiWrap, +} from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; -import { Item as TimelineItem, Timeline } from '../components/timeline'; +import { Timeline, Item as TimelineItem } from '../components/timeline'; const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language); type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiMicrosoftWindows, + iconColor: '#357EC7', + title: 'Hidden files in Windows are cursed', + description: + 'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.', + link: { + url: 'https://github.com/immich-app/immich/pull/12812', + text: '#12812', + }, + date: new Date(2024, 8, 20), + }, + { + icon: mdiWrap, + iconColor: 'gray', + title: 'Carriage returns in bash scripts are cursed', + description: 'Git can be configured to automatically convert LF to CRLF on checkout and CRLF breaks bash scripts.', + link: { + url: 'https://github.com/immich-app/immich/pull/11613', + text: '#11613', + }, + date: new Date(2024, 7, 7), + }, + { + icon: mdiLockOff, + iconColor: 'red', + title: 'Fetch inside Cloudflare Workers is cursed', + description: + 'Fetch requests in Cloudflare Workers use http by default, even if you explicitly specify https, which can often cause redirect loops.', + link: { + url: 'https://community.cloudflare.com/t/does-cloudflare-worker-allow-secure-https-connection-to-fetch-even-on-flexible-ssl/68051/5', + text: 'Cloudflare', + }, + date: new Date(2024, 7, 7), + }, + { + icon: mdiCrosshairsOff, + iconColor: 'gray', + title: 'GPS sharing on mobile is cursed', + description: + 'Some phones will silently strip GPS data from images when apps without location permission try to access them.', + link: { + url: 'https://github.com/immich-app/immich/discussions/11268', + text: '#11268', + }, + date: new Date(2024, 6, 21), + }, { icon: mdiLeadPencil, iconColor: 'gold', @@ -52,6 +113,51 @@ const items: Item[] = [ link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' }, date: new Date(2024, 0, 31), }, + { + icon: mdiBug, + iconColor: 'green', + title: 'ESM imports are cursed', + description: + 'Prior to Node.js v20.8 using --experimental-vm-modules in a CommonJS project that imported an ES module that imported a CommonJS modules would create a segfault and crash Node.js', + link: { + url: 'https://github.com/immich-app/immich/pull/6719', + text: '#6179', + }, + date: new Date(2024, 0, 9), + }, + { + icon: mdiDatabase, + iconColor: 'gray', + title: 'PostgreSQL parameters are cursed', + description: `PostgresSQL has a limit of ${Number(65535).toLocaleString()} parameters, so bulk inserts can fail with large datasets.`, + link: { + url: 'https://github.com/immich-app/immich/pull/6034', + text: '#6034', + }, + date: new Date(2023, 11, 28), + }, + { + icon: mdiSecurity, + iconColor: 'gold', + title: 'Secure contexts are cursed', + description: `Some web features like the clipboard API only work in "secure contexts" (ie. https or localhost)`, + link: { + url: 'https://github.com/immich-app/immich/issues/2981', + text: '#2981', + }, + date: new Date(2023, 5, 26), + }, + { + icon: mdiTrashCan, + iconColor: 'gray', + title: 'TypeORM deletes are cursed', + description: `The remove implementation in TypeORM mutates the input, deleting the id property from the original object.`, + link: { + url: 'https://github.com/typeorm/typeorm/issues/7024#issuecomment-948519328', + text: 'typeorm#6034', + }, + date: new Date(2023, 1, 23), + }, ]; export default function CursedKnowledgePage(): JSX.Element { diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index a375efb8a8..a5dbc7aa98 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -2,46 +2,82 @@ 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 Icon from '@mdi/react'; function HomepageHeader() { const { isDarkTheme } = useColorMode(); return (
-
+
+ Immich logo +
+
+
Immich logo -
-

- Self-hosted photo and - video management solution +

+

+ Self-hosted{' '} + + photo and + video management{' '} + + solution +

+ +

+ Easily back up, organize, and manage your photos on your own server. Immich helps you + browse, search and organize your photos and videos with ease, without + sacrificing your privacy.

-
+ +
Get started - Demo portal - - - - Discord + Demo
- screenshots + +
+ + Join our Discord +
+ screenshots + +
+
+
+ + Immich logo + +
+

Download mobile app

+

+ Download Immich app and start backing up your photos and videos securely to your own server +

+
+ + app qr code
); @@ -61,13 +104,9 @@ function HomepageHeader() { export default function Home(): JSX.Element { return ( - + -
+

This project is available under GNU AGPL v3 license.

Privacy should not be a luxury

diff --git a/docs/src/pages/privacy-policy.tsx b/docs/src/pages/privacy-policy.tsx new file mode 100644 index 0000000000..9ffce50ed9 --- /dev/null +++ b/docs/src/pages/privacy-policy.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import Layout from '@theme/Layout'; +import { useColorMode } from '@docusaurus/theme-common'; +function HomepageHeader() { + const { isDarkTheme } = useColorMode(); + + return ( +
+
+
+

Privacy Policy

+

Last updated: July 31st 2024

+

+ Welcome to Immich. We are committed to respecting your privacy. This Privacy Policy sets out how we collect, + use, and share information when you use our Immich app. +

+
+ + {/* 1. Scope of This Policy */} +
+

1. Scope of This Policy

+

+ This Privacy Policy applies to the Immich app ("we", "our", or "us") and covers our collection, use, and + disclosure of your information. This Policy does not cover any third-party websites, services, or + applications that can be accessed through our app, or third-party services you may access through Immich. +

+
+ + {/* 2. Information We Collect */} +
+

2. Information We Collect

+
+

+ Locally Stored Data: Immich stores all your photos, albums, settings, and locally on your + device. We do not have access to this data, nor do we transmit or store it on any of our servers. +

+
+ +
+

+ Purchase Information: When you make a purchase within the{' '} + https://buy.immich.app, we collect the following information for tax + calculation purposes: +

+
    +
  • Country of origin
  • +
  • Postal code (if the user is from Canada or the United States)
  • +
+
+
+ + {/* 3. Use of Your Information */} +
+

3. Use of Your Information

+

+ Tax Calculation: The country of origin and postal code (for users from Canada or the United + States) are collected solely for determining the applicable tax rates on your purchase. +

+
+ + {/* 4. Sharing of Your Information */} +
+

4. Sharing of Your Information

+
    +
  • + Tax Authorities: The purchase information may be shared with tax authorities as required + by law. +
  • +
  • + Payment Providers: The purchase information may be shared with payment providers where + required. +
  • +
+
+ + {/* 5. Changes to This Policy */} +
+

5. Changes to This Policy

+

+ We may update our Privacy Policy from time to time. If we make any changes, we will notify you by revising + the "Last updated" date at the top of this policy. It's encouraged that users frequently check this page for + any changes to stay informed about how we are helping to protect the personal information we collect. +

+
+ + {/* 6. Contact Us */} +
+

6. Contact Us

+

+ If you have any questions about this Privacy Policy, please contact us at{' '} + immich@futo.org +

+
+
+
+ ); +} + +export default function Home(): JSX.Element { + return ( + + +
+

This project is available under GNU AGPL v3 license.

+

Privacy should not be a luxury

+
+
+ ); +} diff --git a/docs/src/pages/roadmap.tsx b/docs/src/pages/roadmap.tsx index d8e5032e90..1f07e45122 100644 --- a/docs/src/pages/roadmap.tsx +++ b/docs/src/pages/roadmap.tsx @@ -15,6 +15,7 @@ import { mdiCloudUploadOutline, mdiCollage, mdiContentDuplicate, + mdiCrop, mdiDevices, mdiEmailOutline, mdiExpansionCard, @@ -26,6 +27,7 @@ import { mdiFileSearch, mdiFlash, mdiFolder, + mdiFolderMultiple, mdiForum, mdiHandshakeOutline, mdiHeart, @@ -36,6 +38,7 @@ import { mdiImageMultipleOutline, mdiImageSearch, mdiKeyboardSettingsOutline, + mdiLicense, mdiLockOutline, mdiMagnify, mdiMagnifyScan, @@ -55,25 +58,32 @@ import { mdiScaleBalance, mdiSecurity, mdiServer, + mdiShare, mdiShareAll, mdiShareCircle, mdiStar, + mdiStarOutline, mdiTableKey, mdiTag, + mdiTagMultiple, mdiText, mdiThemeLightDark, mdiTrashCanOutline, mdiVectorCombine, + mdiFolderSync, + mdiFaceRecognition, mdiVideo, mdiWeb, - mdiLicense, } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; import { Item, Timeline } from '../components/timeline'; const releases = { - // TODO + 'v1.114.0': new Date(2024, 8, 6), + 'v1.113.0': new Date(2024, 7, 30), + 'v1.112.0': new Date(2024, 7, 14), + 'v1.111.0': new Date(2024, 6, 26), 'v1.110.0': new Date(2024, 5, 11), 'v1.109.0': new Date(2024, 6, 18), 'v1.106.1': new Date(2024, 5, 11), @@ -224,6 +234,60 @@ const roadmap: Item[] = [ ]; const milestones: Item[] = [ + withRelease({ + icon: mdiFaceRecognition, + title: 'Metadata Face Import', + description: 'Read face metadata in Digikam format during import', + release: 'v1.114.0', + }), + withRelease({ + icon: mdiTagMultiple, + iconColor: 'orange', + title: 'Tags', + description: 'Tag your photos and videos', + release: 'v1.113.0', + }), + withRelease({ + icon: mdiFolderSync, + iconColor: 'green', + title: 'Album sync (mobile)', + description: 'Sync or mirror an album from your phone to the Immich server', + release: 'v1.113.0', + }), + withRelease({ + icon: mdiFolderMultiple, + iconColor: 'brown', + title: 'Folders', + description: 'Browse your photos and videos in their folder structure', + release: 'v1.113.0', + }), + withRelease({ + icon: mdiPalette, + title: 'Theming (mobile)', + description: 'Pick a primary color for the mobile app', + release: 'v1.112.0', + }), + withRelease({ + icon: mdiStarOutline, + iconColor: 'gold', + title: 'Star rating', + description: 'Rate your photos and videos', + release: 'v1.112.0', + }), + withRelease({ + icon: mdiCrop, + iconColor: 'royalblue', + title: 'Editor (mobile)', + description: 'Crop and rotate on mobile', + release: 'v1.111.0', + }), + withRelease({ + icon: mdiMap, + iconColor: 'green', + title: 'Deploy tiles.immich.cloud', + description: 'Dedicated tile server for Immich', + release: 'v1.111.0', + }), { icon: mdiStar, iconColor: 'gold', @@ -231,6 +295,12 @@ const milestones: Item[] = [ description: 'Reached 40K Stars on GitHub!', getDateLabel: withLanguage(new Date(2024, 6, 21)), }, + withRelease({ + icon: mdiShare, + title: 'Deploy my.immich.app', + description: 'Url router for immich links', + release: 'v1.109.0', + }), withRelease({ icon: mdiLicense, iconColor: 'gold', diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index dfd3e47a6b..ac1594e81a 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,64 @@ [ + { + "label": "v1.119.1", + "url": "https://v1.119.1.archive.immich.app" + }, + { + "label": "v1.119.0", + "url": "https://v1.119.0.archive.immich.app" + }, + { + "label": "v1.118.2", + "url": "https://v1.118.2.archive.immich.app" + }, + { + "label": "v1.118.1", + "url": "https://v1.118.1.archive.immich.app" + }, + { + "label": "v1.118.0", + "url": "https://v1.118.0.archive.immich.app" + }, + { + "label": "v1.117.0", + "url": "https://v1.117.0.archive.immich.app" + }, + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, + { + "label": "v1.116.0", + "url": "https://v1.116.0.archive.immich.app" + }, + { + "label": "v1.115.0", + "url": "https://v1.115.0.archive.immich.app" + }, + { + "label": "v1.114.0", + "url": "https://v1.114.0.archive.immich.app" + }, + { + "label": "v1.113.1", + "url": "https://v1.113.1.archive.immich.app" + }, + { + "label": "v1.113.0", + "url": "https://v1.113.0.archive.immich.app" + }, + { + "label": "v1.112.1", + "url": "https://v1.112.1.archive.immich.app" + }, + { + "label": "v1.112.0", + "url": "https://v1.112.0.archive.immich.app" + }, { "label": "v1.111.0", "url": "https://v1.111.0.archive.immich.app" diff --git a/docs/static/img/app-qr-code-dark.svg b/docs/static/img/app-qr-code-dark.svg new file mode 100644 index 0000000000..c2d593ea2a --- /dev/null +++ b/docs/static/img/app-qr-code-dark.svg @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/app-qr-code-light.svg b/docs/static/img/app-qr-code-light.svg new file mode 100644 index 0000000000..d5d225201e --- /dev/null +++ b/docs/static/img/app-qr-code-light.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/docs/static/img/feature-panel.png b/docs/static/img/feature-panel.png new file mode 100644 index 0000000000..8c39fe0d40 Binary files /dev/null and b/docs/static/img/feature-panel.png differ diff --git a/docs/static/img/immich-screenshots.png b/docs/static/img/immich-screenshots.png deleted file mode 100644 index 6123279f2d..0000000000 Binary files a/docs/static/img/immich-screenshots.png and /dev/null differ diff --git a/docs/static/img/immich-screenshots.webp b/docs/static/img/immich-screenshots.webp deleted file mode 100644 index 62cc036797..0000000000 Binary files a/docs/static/img/immich-screenshots.webp and /dev/null differ diff --git a/docs/static/img/logomark-dark.svg b/docs/static/img/logomark-dark.svg new file mode 100644 index 0000000000..51f92109d4 --- /dev/null +++ b/docs/static/img/logomark-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/static/img/logomark-light.svg b/docs/static/img/logomark-light.svg new file mode 100644 index 0000000000..497fbdcf14 --- /dev/null +++ b/docs/static/img/logomark-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/static/img/screenshot-dark.webp b/docs/static/img/screenshot-dark.webp new file mode 100644 index 0000000000..a6a7d0e1d6 Binary files /dev/null and b/docs/static/img/screenshot-dark.webp differ diff --git a/docs/static/img/screenshot-light.webp b/docs/static/img/screenshot-light.webp new file mode 100644 index 0000000000..0d88697f47 Binary files /dev/null and b/docs/static/img/screenshot-light.webp differ diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index d3ed1f3cda..98f69bcd59 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -4,20 +4,20 @@ module.exports = { corePlugins: { preflight: false, // disable Tailwind's reset }, - content: ['./src/**/*.{js,jsx,ts,tsx}', '../docs/**/*.mdx'], // my markdown stuff is in ../docs, not /src + content: ['./src/**/*.{js,jsx,ts,tsx}', './{docs,blog}/**/*.{md,mdx}'], // my markdown stuff is in ../docs, not /src darkMode: ['class', '[data-theme="dark"]'], // hooks into docusaurus' dark mode settigns theme: { extend: { colors: { // Light Theme 'immich-primary': '#4250af', - 'immich-bg': 'white', + 'immich-bg': '#f9f8fb', 'immich-fg': 'black', 'immich-gray': '#F6F6F4', // Dark Theme 'immich-dark-primary': '#adcbfa', - 'immich-dark-bg': 'black', + 'immich-dark-bg': '#070a14', 'immich-dark-fg': '#e5e7eb', 'immich-dark-gray': '#212121', }, diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs deleted file mode 100644 index 3594073202..0000000000 --- a/e2e/.eslintrc.cjs +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prefer-module': 'off', - 'unicorn/import-style': 'off', - curly: 2, - 'prettier/prettier': 0, - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/filename-case': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/prefer-event-target': 'off', - 'unicorn/no-thenable': 'off', - }, -}; diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 436613d4a8..f8f41eac46 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - name: immich-e2e services: @@ -21,10 +19,11 @@ services: - DB_PASSWORD=postgres - DB_DATABASE_NAME=immich - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_METRICS=true + - IMMICH_TELEMETRY_INCLUDE=all - IMMICH_ENV=testing + - IMMICH_PORT=2285 + - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true volumes: - - upload:/usr/src/app/upload - ./test-assets:/test-assets extra_hosts: - 'auth-server:host-gateway' @@ -32,10 +31,10 @@ services: - redis - database ports: - - 2283:3001 + - 2285:2285 redis: - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 @@ -45,8 +44,4 @@ services: POSTGRES_USER: postgres POSTGRES_DB: immich ports: - - 5433:5432 - -volumes: - model-cache: - upload: + - 5435:5432 diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs new file mode 100644 index 0000000000..fd1e8a0af6 --- /dev/null +++ b/e2e/eslint.config.mjs @@ -0,0 +1,65 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 [ + { + ignores: ['eslint.config.mjs'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prefer-module': 'off', + 'unicorn/import-style': 'off', + curly: 2, + 'prettier/prettier': 0, + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/prefer-event-target': 'off', + 'unicorn/no-thenable': 'off', + 'object-shorthand': ['error', 'always'], + }, + }, +]; diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 96625348fd..24f3bfdeee 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,31 +1,34 @@ { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.119.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.119.1", "license": "GNU Affero General Public License version 3", "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", - "@typescript-eslint/eslint-plugin": "^7.1.0", - "@typescript-eslint/parser": "^7.1.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", - "exiftool-vendored": "^28.0.0", + "exiftool-vendored": "^28.3.1", + "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", "oidc-provider": "^8.5.1", @@ -42,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.13", + "version": "2.2.28", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -54,30 +57,33 @@ "immich": "dist/index.js" }, "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/sdk": "file:../open-api/typescript-sdk", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.14.12", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@types/node": "^22.8.1", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", "typescript": "^5.3.3", "vite": "^5.0.12", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5", - "vitest-fetch-mock": "^0.3.0", + "vitest-fetch-mock": "^0.4.0", "yaml": "^2.3.1" }, "engines": { @@ -86,14 +92,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.119.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "typescript": "^5.3.3" } }, @@ -355,6 +361,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -371,6 +378,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -387,6 +395,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -403,6 +412,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -419,6 +429,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -435,6 +446,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -451,6 +463,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -467,6 +480,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -483,6 +497,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -499,6 +514,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -515,6 +531,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -531,6 +548,7 @@ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -547,6 +565,7 @@ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -563,6 +582,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -579,6 +599,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -595,6 +616,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -611,6 +633,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -627,6 +650,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -643,6 +667,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -659,6 +684,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -675,6 +701,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -691,6 +718,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -707,6 +735,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -731,24 +760,51 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -756,33 +812,80 @@ "strip-json-comments": "^3.1.1" }, "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/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@eslint/js": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "levn": "^0.4.1" }, "engines": { - "node": ">=10.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -798,11 +901,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@immich/cli": { "resolved": "../cli", @@ -949,19 +1060,18 @@ } }, "node_modules/@koa/router": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.1.tgz", - "integrity": "sha512-ribfPYfHb+Uw3b27Eiw6NPqjhIhTpVFzEWLwyc/1Xp+DCdwRRyIlAUODX+9bPARF6aQtUu1+/PHzdNvRzcs/+Q==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.0.tgz", + "integrity": "sha512-mNVu1nvkpSd8Q8gMebGbCkDWJ51ODetrFvLKYusej+V0ByD4btqHYnPIzTBLXnQMVUlm/oxVwqmWBY3zQfZilw==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^4.3.4", "http-errors": "^2.0.0", "koa-compose": "^4.1.0", - "methods": "^1.1.2", - "path-to-regexp": "^6.2.1" + "path-to-regexp": "^6.3.0" }, "engines": { - "node": ">= 12" + "node": ">= 18" } }, "node_modules/@mapbox/node-pre-gyp": { @@ -1013,6 +1123,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -1026,6 +1137,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -1035,6 +1147,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -1044,10 +1157,11 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==", - "dev": true + "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==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1072,13 +1186,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", - "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz", + "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.45.3" + "playwright": "1.48.1" }, "bin": { "playwright": "cli.js" @@ -1088,208 +1202,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.1.tgz", - "integrity": "sha512-XzqSg714++M+FXhHfXpS1tDnNZNpgxxuGZWlRG/jSj+VEPmZ0yg6jV4E0AL3uyBKxO8mO3xtOsP5mQ+XLfrlww==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.1.tgz", - "integrity": "sha512-thFUbkHteM20BGShD6P08aungq4irbIZKUNbG70LN8RkO7YztcGPiKTTGZS7Kw+x5h8hOXs0i4OaHwFxlpQN6A==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.1.tgz", - "integrity": "sha512-8o6eqeFZzVLia2hKPUZk4jdE3zW7LCcZr+MD18tXkgBBid3lssGVAYuox8x6YHoEPDdDa9ixTaStcmx88lio5Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.1.tgz", - "integrity": "sha512-4T42heKsnbjkn7ovYiAdDVRRWZLU9Kmhdt6HafZxFcUdpjlBlxj4wDrt1yFWLk7G4+E+8p2C9tcmSu0KA6auGA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.1.tgz", - "integrity": "sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.1.tgz", - "integrity": "sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.1.tgz", - "integrity": "sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.1.tgz", - "integrity": "sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.1.tgz", - "integrity": "sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.1.tgz", - "integrity": "sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.1.tgz", - "integrity": "sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.1.tgz", - "integrity": "sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.1.tgz", - "integrity": "sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.1.tgz", - "integrity": "sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.1.tgz", - "integrity": "sha512-LdxxcqRVSXi6k6JUrTah1rHuaupoeuiv38du8Mt4r4IPer3kwlTo+RuvfE8KzZ/tL6BhaPlzJ3835i6CxrFIRQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.1.tgz", - "integrity": "sha512-2bIrL28PcK3YCqD9anGxDxamxdiJAxA+l7fWIwM5o8UqNy1t3d1NdAweO2XhA0KTDJ5aH1FsuiT5+7VhtHliXg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1378,10 +1508,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/express": { "version": "4.17.21", @@ -1425,6 +1556,13 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -1475,13 +1613,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "22.8.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", + "integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.8" } }, "node_modules/@types/normalize-package-data": { @@ -1491,20 +1629,22 @@ "dev": true }, "node_modules/@types/oidc-provider": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.5.1.tgz", - "integrity": "sha512-NS8tBPOj9GG6SxyrUHWBzglOtAYNDX41J4cRE45oeK0iSqI6V6tDW70aPWg25pJFNSC1evccXFm9evfwjxm7HQ==", + "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==", "dev": true, + "license": "MIT", "dependencies": { "@types/koa": "*", "@types/node": "*" } }, "node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -1516,6 +1656,7 @@ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "dev": true, + "license": "MIT", "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", @@ -1534,6 +1675,7 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1543,6 +1685,7 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", "dev": true, + "license": "MIT", "dependencies": { "obuf": "~1.1.2" }, @@ -1555,6 +1698,7 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1564,6 +1708,7 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1632,32 +1777,32 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", - "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/type-utils": "7.17.0", - "@typescript-eslint/utils": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1666,27 +1811,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1695,17 +1840,17 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", - "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1713,27 +1858,24 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", - "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.56.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -1741,13 +1883,13 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", - "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1755,23 +1897,23 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", - "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -1810,66 +1952,61 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", - "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", - "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz", + "integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -1879,17 +2016,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.3", + "vitest": "2.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1897,11 +2041,40 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", + "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -1910,12 +2083,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.3", "pathe": "^1.1.2" }, "funding": { @@ -1923,13 +2097,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -1937,10 +2112,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -1949,13 +2125,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1983,9 +2159,9 @@ } }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "license": "MIT", "bin": { @@ -2000,6 +2176,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2078,16 +2255,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2099,6 +2266,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -2205,6 +2373,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2314,6 +2483,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -2361,6 +2531,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -2548,12 +2719,13 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2596,6 +2768,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2691,31 +2864,6 @@ "wrappy": "1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2750,16 +2898,17 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", + "integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", "dev": true, + "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": { @@ -2807,6 +2956,7 @@ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -2867,58 +3017,64 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "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" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -2998,30 +3154,18 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", "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" @@ -3039,18 +3183,45 @@ "url": "https://opencollective.com/eslint" } }, - "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==", + "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", + "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/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.1.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.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3073,6 +3244,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -3094,6 +3266,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -3108,10 +3281,11 @@ } }, "node_modules/eta": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eta/-/eta-3.4.0.tgz", - "integrity": "sha512-tCsc7WXTjrTx4ZjYLplcqrI3o4mYJ+Z6YspeuGL8tbt/hHoMchwBwtKfwM09svEY86iRapY93vUqQttcNuIO5Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" }, @@ -3119,51 +3293,28 @@ "url": "https://github.com/eta-dev/eta?sponsor=1" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", + "integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "dev": true, "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0" + "exiftool-vendored.exe": "12.97.0", + "exiftool-vendored.pl": "12.97.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", + "integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "dev": true, "license": "MIT", "optional": true, @@ -3172,9 +3323,9 @@ ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", + "integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "dev": true, "license": "MIT", "optional": true, @@ -3247,20 +3398,22 @@ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -3293,24 +3446,25 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -3453,15 +3607,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3481,18 +3626,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3526,36 +3659,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3831,22 +3941,14 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "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.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, + "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -3999,27 +4101,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4092,10 +4173,11 @@ } }, "node_modules/jose": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz", - "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==", + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -4297,13 +4379,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -4324,10 +4404,11 @@ "dev": true }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -4376,12 +4457,6 @@ "node": ">= 0.6" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4402,9 +4477,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -4448,18 +4523,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -4540,10 +4603,11 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.7", @@ -4556,6 +4620,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -4658,33 +4723,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -4731,33 +4769,34 @@ "dev": true }, "node_modules/oidc-provider": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.5.1.tgz", - "integrity": "sha512-Bm3EyxN68/KS76IlciJ3+4pnVtfdRWL+NghWpIF0XQbiRT1gzc6Qf/cyFmpL9yieko/jXYZ/uLHUv77jD00qww==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.5.2.tgz", + "integrity": "sha512-WhMIQ61KMgABvrYZJDuefOWFpX34DWgg+U4juKARplGhNUSNfHgJh6i6Mp+PTO08ZDk/oj1Ci7ScU6CAI/wgcg==", "dev": true, + "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", - "@koa/router": "^12.0.1", - "debug": "^4.3.5", - "eta": "^3.4.0", + "@koa/router": "^13.1.0", + "debug": "^4.3.7", + "eta": "^3.5.0", "got": "^13.0.0", - "jose": "^5.6.2", + "jose": "^5.9.4", "jsesc": "^3.0.2", "koa": "^2.15.3", "nanoid": "^5.0.7", "object-hash": "^3.0.0", "oidc-token-hash": "^5.0.3", "quick-lru": "^7.0.0", - "raw-body": "^2.5.2" + "raw-body": "^3.0.0" }, "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/oidc-provider/node_modules/nanoid": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz", - "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", + "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==", "dev": true, "funding": [ { @@ -4765,6 +4804,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.js" }, @@ -4802,21 +4842,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", @@ -4995,46 +5020,38 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5061,10 +5078,11 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", - "dev": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -5085,19 +5103,21 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "dev": true, + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", - "dev": true + "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==", + "dev": true, + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", @@ -5125,10 +5145,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -5144,13 +5165,13 @@ } }, "node_modules/playwright": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", - "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz", + "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.45.3" + "playwright-core": "1.48.1" }, "bin": { "playwright": "cli.js" @@ -5163,9 +5184,9 @@ } }, "node_modules/playwright-core": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", - "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz", + "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5194,9 +5215,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -5212,10 +5233,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5304,21 +5326,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -5366,7 +5384,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/quick-lru": { "version": "7.0.0", @@ -5381,14 +5400,15 @@ } }, "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==", "dev": true, + "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": { @@ -5593,6 +5613,7 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -5614,12 +5635,13 @@ } }, "node_modules/rollup": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.1.tgz", - "integrity": "sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -5629,22 +5651,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.19.1", - "@rollup/rollup-android-arm64": "4.19.1", - "@rollup/rollup-darwin-arm64": "4.19.1", - "@rollup/rollup-darwin-x64": "4.19.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.19.1", - "@rollup/rollup-linux-arm-musleabihf": "4.19.1", - "@rollup/rollup-linux-arm64-gnu": "4.19.1", - "@rollup/rollup-linux-arm64-musl": "4.19.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.19.1", - "@rollup/rollup-linux-riscv64-gnu": "4.19.1", - "@rollup/rollup-linux-s390x-gnu": "4.19.1", - "@rollup/rollup-linux-x64-gnu": "4.19.1", - "@rollup/rollup-linux-x64-musl": "4.19.1", - "@rollup/rollup-win32-arm64-msvc": "4.19.1", - "@rollup/rollup-win32-ia32-msvc": "4.19.1", - "@rollup/rollup-win32-x64-msvc": "4.19.1", + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" } }, @@ -5667,6 +5689,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -5695,7 +5718,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/semver": { "version": "7.6.2", @@ -5795,25 +5819,16 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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==", - "dev": true, "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": { @@ -5834,10 +5849,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -5967,18 +5983,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -6169,10 +6173,18 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -6193,10 +6205,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -6277,18 +6290,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -6303,9 +6304,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6317,10 +6318,11 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -6410,14 +6412,15 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -6436,6 +6439,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -6453,6 +6457,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -6465,15 +6472,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -6492,6 +6499,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -6501,29 +6509,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6538,8 +6547,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", "happy-dom": "*", "jsdom": "*" }, @@ -6748,9 +6757,9 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", + "integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", "dev": true, "engines": { "node": ">=0.4.0" diff --git a/e2e/package.json b/e2e/package.json index 144a369dff..86488e8a70 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.111.0", + "version": "1.119.1", "description": "", "main": "index.js", "type": "module", @@ -19,23 +19,26 @@ "author": "", "license": "GNU Affero General Public License version 3", "devDependencies": { + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", "@immich/cli": "file:../cli", "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", - "@typescript-eslint/eslint-plugin": "^7.1.0", - "@typescript-eslint/parser": "^7.1.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", - "exiftool-vendored": "^28.0.0", + "exiftool-vendored": "^28.3.1", + "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", "oidc-provider": "^8.5.1", @@ -50,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 65a9c78823..2576a2c5c9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: 1, reporter: 'html', use: { - baseURL: 'http://127.0.0.1:2283', + baseURL: 'http://127.0.0.1:2285', trace: 'on-first-retry', }, @@ -53,8 +53,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'docker compose up --build -V --remove-orphans', - url: 'http://127.0.0.1:2283', + command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans', + url: 'http://127.0.0.1:2285', + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: true, }, }); diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 2a35eb3c92..9e925c4021 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -344,16 +344,16 @@ describe('/albums', () => { }); }); - describe('GET /albums/count', () => { + describe('GET /albums/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/albums/count'); + const { status, body } = await request(app).get('/albums/statistics'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return total count of albums the user has access to', async () => { const { status, body } = await request(app) - .get('/albums/count') + .get('/albums/statistics') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts new file mode 100644 index 0000000000..1748276625 --- /dev/null +++ b/e2e/src/api/specs/api-key.e2e-spec.ts @@ -0,0 +1,258 @@ +import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string, permissions: Permission[]) => + createApiKey({ apiKeyCreateDto: { name: 'api key', permissions } }, { headers: asBearerAuth(accessToken) }); + +describe('/api-keys', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + beforeEach(async () => { + await utils.resetDatabase(['api_keys']); + }); + + describe('POST /api-keys', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/api-keys').send({ name: 'API Key' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not work without permission', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]); + const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('apiKey.create')); + }); + + it('should work with apiKey.create', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ + name: 'API Key', + permissions: [Permission.ApiKeyRead], + }); + expect(body).toEqual({ + secret: expect.any(String), + apiKey: { + id: expect.any(String), + name: 'API Key', + permissions: [Permission.ApiKeyRead], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }); + expect(status).toBe(201); + }); + + it('should not create an api key with all permissions', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ name: 'API Key', permissions: [Permission.All] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have')); + }); + + it('should not create an api key with more permissions', async () => { + const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]); + const { status, body } = await request(app) + .post('/api-keys') + .set('x-api-key', secret) + .send({ name: 'API Key', permissions: [Permission.ApiKeyRead] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have')); + }); + + it('should create an api key', async () => { + const { status, body } = await request(app) + .post('/api-keys') + .send({ name: 'API Key', permissions: [Permission.All] }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual({ + apiKey: { + id: expect.any(String), + name: 'API Key', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + secret: expect.any(String), + }); + expect(status).toEqual(201); + }); + }); + + describe('GET /api-keys', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/api-keys'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of api keys', async () => { + const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ + create(admin.accessToken, [Permission.All]), + create(admin.accessToken, [Permission.All]), + create(admin.accessToken, [Permission.All]), + ]); + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3])); + expect(status).toEqual(200); + }); + }); + + describe('GET /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/api-keys/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app) + .get(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/api-keys/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get api key details', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app) + .get(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'api key', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/api-keys/${uuidDto.notFound}`).send({ name: 'new name' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app) + .put(`/api-keys/${apiKey.id}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .put(`/api-keys/${uuidDto.invalid}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should update api key details', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app) + .put(`/api-keys/${apiKey.id}`) + .send({ name: 'new name' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'new name', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('DELETE /api-keys/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/api-keys/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app) + .delete(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('API Key not found')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/api-keys/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete an api key', async () => { + const { apiKey } = await create(user.accessToken, [Permission.All]); + const { status } = await request(app) + .delete(`/api-keys/${apiKey.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + }); + + describe('authentication', () => { + it('should work as a header', async () => { + const { secret } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret); + expect(body).toHaveLength(1); + expect(status).toBe(200); + }); + + it('should work as a query param', async () => { + const { secret } = await create(user.accessToken, [Permission.All]); + const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`); + expect(body).toHaveLength(1); + expect(status).toBe(200); + }); + }); +}); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 067ebbebcc..cc898e7468 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -6,8 +6,9 @@ import { LoginResponseDto, SharedLinkType, getAssetInfo, + getConfig, getMyUser, - updateAssets, + updateConfig, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -43,6 +44,10 @@ const makeUploadDto = (options?: { omit: string }): Record => { const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; +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); @@ -66,25 +71,23 @@ describe('/asset', () => { let timeBucketUser: LoginResponseDto; let quotaUser: LoginResponseDto; let statsUser: LoginResponseDto; - let stackUser: LoginResponseDto; let user1Assets: AssetMediaResponseDto[]; let user2Assets: AssetMediaResponseDto[]; - let stackAssets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; + let ratingAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.create('1')), utils.userSetup(admin.accessToken, createUserDto.create('2')), utils.userSetup(admin.accessToken, createUserDto.create('stats')), utils.userSetup(admin.accessToken, createUserDto.userQuota), utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), - utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); await utils.createPartner(user1.accessToken, user2.userId); @@ -99,6 +102,16 @@ describe('/asset', () => { await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); + // asset rating + ratingAsset = await utils.createAsset(admin.accessToken, { + assetData: { + filename: 'mongolels.jpg', + bytes: await readFile(ratingAssetFilepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: ratingAsset.id }); + user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), @@ -137,20 +150,6 @@ describe('/asset', () => { }), ]); - // stacks - stackAssets = await Promise.all([ - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - utils.createAsset(stackUser.accessToken), - ]); - - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -214,6 +213,80 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id }); }); + it('should get the asset rating', async () => { + await utils.waitForWebsocketEvent({ + event: 'assetUpload', + id: ratingAsset.id, + }); + + const { status, body } = await request(app) + .get(`/assets/${ratingAsset.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: ratingAsset.id, + exifInfo: expect.objectContaining({ rating: 3 }), + }); + }); + + it('should get the asset faces', async () => { + const config = await getSystemConfig(admin.accessToken); + config.metadata.faces.import = true; + await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); + + // asset faces + const facesAsset = await utils.createAsset(admin.accessToken, { + assetData: { + filename: 'portrait.jpg', + bytes: await readFile(facesAssetFilepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: facesAsset.id }); + + const { status, body } = await request(app) + .get(`/assets/${facesAsset.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body.id).toEqual(facesAsset.id); + expect(body.people).toMatchObject([ + { + name: 'Marie Curie', + birthDate: null, + thumbnailPath: '', + isHidden: false, + faces: [ + { + imageHeight: 700, + imageWidth: 840, + boundingBoxX1: 261, + boundingBoxX2: 356, + boundingBoxY1: 146, + boundingBoxY2: 284, + sourceType: 'exif', + }, + ], + }, + { + name: 'Pierre Curie', + birthDate: null, + thumbnailPath: '', + isHidden: false, + faces: [ + { + imageHeight: 700, + imageWidth: 840, + boundingBoxX1: 536, + boundingBoxX2: 618, + boundingBoxY1: 83, + boundingBoxY2: 252, + sourceType: 'exif', + }, + ], + }, + ]); + }); + it('should work with a shared link', async () => { const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, @@ -353,6 +426,8 @@ describe('/asset', () => { utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken), ]); + + await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration'); }); it('should require authentication', async () => { @@ -389,17 +464,14 @@ describe('/asset', () => { } }); - it.each(TEN_TIMES)( - 'should return 1 asset if there are 10 assets in the database but user 2 only has 1', - async () => { - const { status, body } = await request(app) - .get('/assets/random') - .set('Authorization', `Bearer ${user2.accessToken}`); + it.skip('should return 1 asset if there are 10 assets in the database but user 2 only has 1', async () => { + const { status, body } = await request(app) + .get('/assets/random') + .set('Authorization', `Bearer ${user2.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]); - }, - ); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: user2Assets[0].id })]); + }); it('should return error', async () => { const { status } = await request(app) @@ -472,6 +544,48 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should not allow linking two photos', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: user1Assets[1].id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video must be a video')); + expect(status).toEqual(400); + }); + + it('should not allow linking a video owned by another user', async () => { + const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user')); + expect(status).toEqual(400); + }); + + it('should link a motion photo', async () => { + const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); + }); + + it('should unlink a motion photo', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: null }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null }); + }); + it('should update date time original when sidecar file contains DateTimeOriginal', async () => { const sidecarData = ` @@ -575,6 +689,31 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should set the rating', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ rating: 2 }); + expect(body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + rating: 2, + }), + }); + 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) + .put(`/assets/${user1Assets[0].id}`) + .send(test) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + } + }); + it('should return tagged people', async () => { const { status, body } = await request(app) .put(`/assets/${user1Assets[0].id}`) @@ -773,145 +912,8 @@ describe('/asset', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should add stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[1].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - - it('should remove all stack children', async () => { - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - // create stack after previous test removed stack children - await updateAssets( - { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, - { headers: asBearerAuth(stackUser.accessToken) }, - ); - - const { status } = await request(app) - .put('/assets') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); - - expect(status).toBe(204); - - const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[0].id }), - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - ]), - ); - }); }); - describe('PUT /assets/stack/parent', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put('/assets/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(app) - .put('/assets/stack/parent') - .set('Authorization', `Bearer ${stackUser.accessToken}`) - .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); - - expect(status).toBe(200); - - const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); - - // new parent - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: stackAssets[1].id }), - expect.objectContaining({ id: stackAssets[2].id }), - expect.objectContaining({ id: stackAssets[3].id }), - ]), - ); - }); - }); describe('POST /assets', () => { beforeAll(setupTests, 30_000); @@ -940,13 +942,12 @@ describe('/asset', () => { expect(body).toEqual(errorDto.badRequest()); }); - it.each([ + const tests = [ { input: 'formats/avif/8bit-sRGB.avif', expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.avif', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -962,7 +963,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'el_torcal_rocks.jpg', - resized: true, exifInfo: { dateTimeOriginal: '2012-08-05T11:39:59.000Z', exifImageWidth: 512, @@ -986,7 +986,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '8bit-sRGB.jxl', - resized: true, exifInfo: { description: '', exifImageHeight: 1080, @@ -1002,7 +1001,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'IMG_2682.heic', - resized: true, fileCreatedAt: '2019-03-21T16:04:22.348Z', exifInfo: { dateTimeOriginal: '2019-03-21T16:04:22.348Z', @@ -1027,7 +1025,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'density_plot.png', - resized: true, exifInfo: { exifImageWidth: 800, exifImageHeight: 800, @@ -1042,7 +1039,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'glarus.nef', - resized: true, fileCreatedAt: '2010-07-20T17:27:12.000Z', exifInfo: { make: 'NIKON CORPORATION', @@ -1064,8 +1060,7 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: 'philadelphia.nef', - resized: true, - fileCreatedAt: '2016-09-22T22:10:29.060Z', + fileCreatedAt: '2016-09-22T21:10:29.060Z', exifInfo: { make: 'NIKON CORPORATION', model: 'NIKON D700', @@ -1074,11 +1069,11 @@ describe('/asset', () => { focalLength: 85, iso: 200, fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', + dateTimeOriginal: '2016-09-22T21:10:29.060Z', latitude: null, longitude: null, orientation: '1', - timeZone: 'UTC-5', + timeZone: 'UTC-4', }, }, }, @@ -1087,7 +1082,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '4_3.rw2', - resized: true, fileCreatedAt: '2018-05-10T08:42:37.842Z', exifInfo: { make: 'Panasonic', @@ -1111,7 +1105,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '12bit-compressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-09-27T10:51:44.000Z', exifInfo: { make: 'SONY', @@ -1136,7 +1129,6 @@ describe('/asset', () => { expected: { type: AssetTypeEnum.Image, originalFileName: '14bit-uncompressed-(3_2).arw', - resized: true, fileCreatedAt: '2016-01-08T14:08:01.000Z', exifInfo: { make: 'SONY', @@ -1156,21 +1148,32 @@ describe('/asset', () => { }, }, }, - ])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { - const filepath = join(testAssetDir, input); - const { id, status } = await utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); + ]; - expect(status).toBe(AssetMediaStatus.Created); + it(`should upload and generate a thumbnail for different file types`, async () => { + // upload in parallel + const assets = await Promise.all( + tests.map(async ({ input }) => { + const filepath = join(testAssetDir, input); + return utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + }), + ); - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); + for (const { id, status } of assets) { + expect(status).toBe(AssetMediaStatus.Created); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); + } - const asset = await utils.getAssetInfo(admin.accessToken, id); + for (const [i, { id }] of assets.entries()) { + const { expected } = tests[i]; + const asset = await utils.getAssetInfo(admin.accessToken, id); - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + } }); it('should handle a duplicate', async () => { diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 59968f3b79..bf0bd4a9c6 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,11 +1,4 @@ -import { - LibraryResponseDto, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - removeOfflineFiles, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -15,8 +8,7 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => - scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); describe('/libraries', () => { let admin: LoginResponseDto; @@ -83,7 +75,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -270,7 +262,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -293,14 +285,19 @@ describe('/libraries', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should scan external library', async () => { + it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 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'); const { assets } = await utils.metadataSearch(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -315,8 +312,13 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 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'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -330,8 +332,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + 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'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -340,263 +347,237 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); - it('should pick up new files', async () => { + it('should scan multiple import paths with commas', async () => { + // https://github.com/immich-app/immich/issues/10699 const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + 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'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined(); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); - - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); }); - it('should offline missing files', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); + it('should scan multiple import paths with braces', async () => { + // https://github.com/immich-app/immich/issues/10699 const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); - }); - - it('should not try to delete offline files', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline1`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(initialAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); - - expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); - }); - - it('should scan new files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - originalFileName: 'assetC.png', - }), - ]), - ); - }); - - describe('with refreshModifiedFiles=true', () => { - it('should reimport modified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - - it('should not reimport unmodified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(0); - }); - }); - - describe('with refreshAllFiles=true', () => { - it('should reimport all files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshAllFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - }); - }); - - describe('POST /libraries/:id/removeOffline', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should remove offline files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline2`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline2/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - expect(initialAssets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline2/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); + 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}/removeOffline`) + .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, 'backgroundTask'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined(); + + utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); + }); + + it('should reimport a modified file', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(1); + }); + + it('should not reimport unmodified files', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); expect(assets.count).toBe(0); }); - it('should not remove online files', async () => { + it('should set an asset offline if its file is missing', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(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'); + + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toEqual(true); + + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(newAssets.items).toEqual([]); + }); + + it('should set an asset offline its file is not in any import path', async () => { + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + + utils.createDirectory(`${testAssetDir}/temp/another-path/`); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ 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'); + + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toBe(true); + + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([]); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + utils.removeDirectory(`${testAssetDir}/temp/another-path/`); + }); + + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp`], + }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + originalFileName: 'assetB.png', + }); + expect(assets.count).toBe(1); + + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/directoryB/**'] }); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.isTrashed).toBe(true); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); + expect(trashedAsset.isOffline).toBe(true); + + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'assetA.png', + }), + ]); + }); + + it('should not trash an online asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -609,10 +590,11 @@ describe('/libraries', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -651,6 +633,29 @@ describe('/libraries', () => { }); }); + it("should fail if path isn't absolute", async () => { + const pathToTest = `relative/path`; + + const cwd = process.cwd(); + // Create directory in cwd + utils.createDirectory(`${cwd}/${pathToTest}`); + + const response = await utils.validateLibrary(admin.accessToken, library.id, { + importPaths: [pathToTest], + }); + + utils.removeDirectory(`${cwd}/${pathToTest}`); + + expect(response.importPaths?.length).toEqual(1); + const pathResponse = response?.importPaths?.at(0); + + expect(pathResponse).toEqual({ + importPath: pathToTest, + isValid: false, + message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'), + }); + }); + it('should fail if path is a file', async () => { const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; @@ -704,7 +709,7 @@ describe('/libraries', () => { }); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 343a7c91d0..da5f779cff 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,8 +1,7 @@ -import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); - asset = await utils.createAsset(admin.accessToken); - const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; utils.resetEvents(); const uploadFile = async (input: string) => { @@ -103,63 +97,6 @@ describe('/map', () => { }); }); - describe('GET /map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - }); - describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 8ca17eba81..42989a118f 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -17,6 +17,8 @@ const authServer = { external: 'http://127.0.0.1:3000', }; +const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect'; + const redirect = async (url: string, cookies?: string[]) => { const { headers } = await request(url) .get('/') @@ -24,8 +26,8 @@ const redirect = async (url: string, cookies?: string[]) => { return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location }; }; -const loginWithOAuth = async (sub: OAuthUser | string) => { - const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: `${baseUrl}/auth/login` } }); +const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => { + const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login` } }); // login const response1 = await redirect(url.replace(authServer.internal, authServer.external)); @@ -92,14 +94,14 @@ describe(`/oauth`, () => { it('should return a redirect uri', async () => { const { status, body } = await request(app) .post('/oauth/authorize') - .send({ redirectUri: 'http://127.0.0.1:2283/auth/login' }); + .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); expect(status).toBe(201); expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) }); const params = new URL(body.url).searchParams; expect(params.get('client_id')).toBe('client-default'); expect(params.get('response_type')).toBe('code'); - expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2283/auth/login'); + expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login'); expect(params.get('state')).toBeDefined(); }); }); @@ -255,4 +257,50 @@ describe(`/oauth`, () => { }); }); }); + + describe('mobile redirect override', () => { + beforeAll(async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + buttonText: 'Login with Immich', + storageLabelClaim: 'immich_username', + mobileOverrideEnabled: true, + mobileRedirectUri: mobileOverrideRedirectUri, + }); + }); + + it('should return the mobile redirect uri', async () => { + const { status, body } = await request(app) + .post('/oauth/authorize') + .send({ redirectUri: 'app.immich:///oauth-callback' }); + expect(status).toBe(201); + expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) }); + + const params = new URL(body.url).searchParams; + expect(params.get('client_id')).toBe('client-default'); + expect(params.get('response_type')).toBe('code'); + expect(params.get('redirect_uri')).toBe(mobileOverrideRedirectUri); + expect(params.get('state')).toBeDefined(); + }); + + it('should auto register the user by default', async () => { + const url = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback'); + expect(url).toEqual(expect.stringContaining(mobileOverrideRedirectUri)); + + // simulate redirecting back to mobile app + const redirectUri = url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback'); + + const { status, body } = await request(app).post('/oauth/callback').send({ url: redirectUri }); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + isAdmin: false, + name: 'OAuth User', + userEmail: 'oauth-mobile-override@immich.app', + userId: expect.any(String), + }); + }); + }); }); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index b1116d4d6e..0e5d882f80 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,4 +1,4 @@ -import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; +import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk'; import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -32,9 +32,6 @@ describe('/search', () => { let assetOneJpg5: AssetMediaResponseDto; let assetSprings: AssetMediaResponseDto; let assetLast: AssetMediaResponseDto; - let cities: string[]; - let states: string[]; - let countries: string[]; beforeAll(async () => { await utils.resetDatabase(); @@ -85,7 +82,7 @@ describe('/search', () => { // note: the coordinates here are not the actual coordinates of the images and are random for most of them const coordinates = [ { latitude: 48.853_41, longitude: 2.3488 }, // paris - { latitude: 63.0695, longitude: -151.0074 }, // denali + { latitude: 35.6895, longitude: 139.691_71 }, // tokyo { latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore { latitude: 41.013_84, longitude: 28.949_66 }, // istanbul @@ -101,16 +98,15 @@ describe('/search', () => { { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg - { latitude: 35.6895, longitude: 139.691_71 }, // tokyo ]; - const updates = assets.map((asset, i) => - updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }), + const updates = coordinates.map((dto, i) => + updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }), ); await Promise.all(updates); - for (const asset of assets) { - await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); + for (const [i] of coordinates.entries()) { + await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id }); } [ @@ -137,12 +133,6 @@ describe('/search', () => { assetLast = assets.at(-1) as AssetMediaResponseDto; await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); - - const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) }); - const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id); - cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort(); - states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort(); - countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort(); }, 30_000); afterAll(async () => { @@ -191,7 +181,7 @@ describe('/search', () => { dto: { size: -1.5 }, expected: ['size must not be less than 1', 'size must be an integer number'], }, - ...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({ + ...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({ should: `should reject ${value} not a boolean`, dto: { [value]: 'immich' }, expected: [`${value} must be a boolean value`], @@ -321,23 +311,120 @@ describe('/search', () => { }, { should: 'should search by city', - deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }), + deferred: () => ({ + dto: { + city: 'Accra', + includeNull: true, + }, + assets: [assetHeic], + }), + }, + { + should: "should search city ('')", + deferred: () => ({ + dto: { + city: '', + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), + }, + { + should: 'should search city (null)', + deferred: () => ({ + dto: { + city: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), }, { should: 'should search by state', - deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }), + deferred: () => ({ + dto: { + state: 'New York', + includeNull: true, + }, + assets: [assetDensity], + }), + }, + { + should: "should search state ('')", + deferred: () => ({ + dto: { + state: '', + isVisible: true, + withExif: true, + includeNull: true, + }, + assets: [assetLast, assetNotocactus], + }), + }, + { + should: 'should search state (null)', + deferred: () => ({ + dto: { + state: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast, assetNotocactus], + }), }, { should: 'should search by country', - deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }), + deferred: () => ({ + dto: { + country: 'France', + includeNull: true, + }, + assets: [assetFalcon], + }), + }, + { + should: "should search country ('')", + deferred: () => ({ + dto: { + country: '', + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), + }, + { + should: 'should search country (null)', + deferred: () => ({ + dto: { + country: null, + isVisible: true, + includeNull: true, + }, + assets: [assetLast], + }), }, { should: 'should search by make', - deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), + deferred: () => ({ + dto: { + make: 'Canon', + includeNull: true, + }, + assets: [assetFalcon, assetDenali], + }), }, { should: 'should search by model', - deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), + deferred: () => ({ + dto: { + model: 'Canon EOS 7D', + includeNull: true, + }, + assets: [assetDenali], + }), }, { should: 'should allow searching the upload library (libraryId: null)', @@ -450,32 +537,79 @@ describe('/search', () => { it('should get suggestions for country', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=country') + .get('/search/suggestions?type=country&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual(countries); + expect(body).toEqual([ + 'Cuba', + 'France', + 'Georgia', + 'Germany', + 'Ghana', + 'Japan', + 'Morocco', + "People's Republic of China", + 'Russian Federation', + 'Singapore', + 'Spain', + 'Switzerland', + 'United States of America', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for state', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=state') + .get('/search/suggestions?type=state&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toHaveLength(states.length); - expect(body).toEqual(expect.arrayContaining(states)); + expect(body).toEqual([ + 'Andalusia', + 'Berlin', + 'Glarus', + 'Greater Accra', + 'Havana', + 'Île-de-France', + 'Marrakesh-Safi', + 'Mississippi', + 'New York', + 'Shanghai', + 'St.-Petersburg', + 'Tbilisi', + 'Tokyo', + 'Virginia', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for city', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=city') + .get('/search/suggestions?type=city&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual(cities); + expect(body).toEqual([ + 'Accra', + 'Berlin', + 'Glarus', + 'Havana', + 'Marrakesh', + 'Montalbán de Córdoba', + 'New York City', + 'Novena', + 'Paris', + 'Philadelphia', + 'Saint Petersburg', + 'Shanghai', + 'Stanley', + 'Tbilisi', + 'Tokyo', + null, + ]); expect(status).toBe(200); }); it('should get suggestions for camera make', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=camera-make') + .get('/search/suggestions?type=camera-make&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([ 'Apple', @@ -485,13 +619,14 @@ describe('/search', () => { 'PENTAX Corporation', 'samsung', 'SONY', + null, ]); expect(status).toBe(200); }); it('should get suggestions for camera model', async () => { const { status, body } = await request(app) - .get('/search/suggestions?type=camera-model') + .get('/search/suggestions?type=camera-model&includeNull=true') .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([ 'Canon EOS 7D', @@ -506,6 +641,7 @@ describe('/search', () => { 'SM-F711N', 'SM-S906U', 'SM-T970', + null, ]); expect(status).toBe(200); }); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts deleted file mode 100644 index 092eab3ec5..0000000000 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { LoginResponseDto } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; - -describe('/server-info', () => { - let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); - }); - - describe('GET /server-info/about', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/about'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return about information', async () => { - const { status, body } = await request(app) - .get('/server-info/about') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - version: expect.any(String), - versionUrl: expect.any(String), - repository: 'immich-app/immich', - repositoryUrl: 'https://github.com/immich-app/immich', - build: '1234567890', - buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890', - buildImage: 'e2e', - buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server', - sourceRef: 'e2e', - sourceCommit: 'e2eeeeeeeeeeeeeeeeee', - sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee', - nodejs: expect.any(String), - ffmpeg: expect.any(String), - imagemagick: expect.any(String), - libvips: expect.any(String), - exiftool: expect.any(String), - licensed: false, - }); - }); - }); - - describe('GET /server-info/storage', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/storage'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return the disk information', async () => { - const { status, body } = await request(app) - .get('/server-info/storage') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - diskAvailable: expect.any(String), - diskAvailableRaw: expect.any(Number), - diskSize: expect.any(String), - diskSizeRaw: expect.any(Number), - diskUsagePercentage: expect.any(Number), - diskUse: expect.any(String), - diskUseRaw: expect.any(Number), - }); - }); - }); - - describe('GET /server-info/ping', () => { - it('should respond with pong', async () => { - const { status, body } = await request(app).get('/server-info/ping'); - expect(status).toBe(200); - expect(body).toEqual({ res: 'pong' }); - }); - }); - - describe('GET /server-info/version', () => { - it('should respond with the server version', async () => { - const { status, body } = await request(app).get('/server-info/version'); - expect(status).toBe(200); - expect(body).toEqual({ - major: expect.any(Number), - minor: expect.any(Number), - patch: expect.any(Number), - }); - }); - }); - - describe('GET /server-info/features', () => { - it('should respond with the server features', async () => { - const { status, body } = await request(app).get('/server-info/features'); - expect(status).toBe(200); - expect(body).toEqual({ - smartSearch: false, - configFile: false, - duplicateDetection: false, - facialRecognition: false, - map: true, - reverseGeocoding: true, - oauth: false, - oauthAutoLaunch: false, - passwordLogin: true, - search: true, - sidecar: true, - trash: true, - email: false, - }); - }); - }); - - describe('GET /server-info/config', () => { - it('should respond with the server configuration', async () => { - const { status, body } = await request(app).get('/server-info/config'); - expect(status).toBe(200); - expect(body).toEqual({ - loginPageMessage: '', - oauthButtonText: 'Login with OAuth', - trashDays: 30, - userDeleteDelay: 7, - isInitialized: true, - externalDomain: '', - isOnboarded: false, - }); - }); - }); - - describe('GET /server-info/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/statistics'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server-info/statistics') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should return the server stats', async () => { - const { status, body } = await request(app) - .get('/server-info/statistics') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - photos: 0, - usage: 0, - usageByUser: [ - { - quotaSizeInBytes: null, - photos: 0, - usage: 0, - userName: 'Immich Admin', - userId: admin.userId, - videos: 0, - }, - { - quotaSizeInBytes: null, - photos: 0, - usage: 0, - userName: 'User 1', - userId: nonAdmin.userId, - videos: 0, - }, - ], - videos: 0, - }); - }); - }); - - describe('GET /server-info/media-types', () => { - it('should return accepted media types', async () => { - const { status, body } = await request(app).get('/server-info/media-types'); - expect(status).toBe(200); - expect(body).toEqual({ - sidecar: ['.xmp'], - image: expect.any(Array), - video: expect.any(Array), - }); - }); - }); - - describe('GET /server-info/theme', () => { - it('should respond with the server theme', async () => { - const { status, body } = await request(app).get('/server-info/theme'); - expect(status).toBe(200); - expect(body).toEqual({ - customCss: '', - }); - }); - }); -}); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index d19744674f..3133460ada 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -110,6 +110,7 @@ describe('/server', () => { facialRecognition: false, map: true, reverseGeocoding: true, + importFaces: false, oauth: false, oauthAutoLaunch: false, passwordLogin: true, @@ -133,6 +134,8 @@ describe('/server', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/e2e/src/api/specs/stack.e2e-spec.ts b/e2e/src/api/specs/stack.e2e-spec.ts new file mode 100644 index 0000000000..bf34369ee3 --- /dev/null +++ b/e2e/src/api/specs/stack.e2e-spec.ts @@ -0,0 +1,211 @@ +import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/stacks', () => { + let admin: LoginResponseDto; + let user1: LoginResponseDto; + let user2: LoginResponseDto; + let asset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + + [user1, user2] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + ]); + + asset = await utils.createAsset(user1.accessToken); + }); + + describe('POST /stacks', () => { + it('should require authentication', async () => { + const { status, body } = await request(app) + .post('/stacks') + .send({ assetIds: [asset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require at least two assets', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const user2Asset = await utils.createAsset(user2.accessToken); + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset.id, user2Asset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should create a stack', async () => { + const [asset1, asset2] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: [expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })], + }); + }); + + it('should merge an existing stack', async () => { + const [asset1, asset2, asset3] = await Promise.all([ + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + ]); + + const response1 = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset2.id] }); + + expect(response1.status).toBe(201); + + const stacksBefore = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + + const { status, body } = await request(app) + .post('/stacks') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ assetIds: [asset1.id, asset3.id] }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + primaryAssetId: asset1.id, + assets: expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + }); + + const stacksAfter = await searchStacks({}, { headers: asBearerAuth(user1.accessToken) }); + expect(stacksAfter.length).toBe(stacksBefore.length); + }); + + // it('should require a valid parent id', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + // }); + }); + + // it('should require access to the parent', async () => { + // const { status, body } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.noPermission); + // }); + + // it('should add stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + // }); + + // it('should remove stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[1].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[2].id }), + // expect.objectContaining({ id: stackAssets[3].id }), + // ]), + // ); + // }); + + // it('should remove all stack children', async () => { + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).toBeUndefined(); + // }); + + // it('should merge stack children', async () => { + // // create stack after previous test removed stack children + // await updateAssets( + // { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + // { headers: asBearerAuth(stackUser.accessToken) }, + // ); + + // const { status } = await request(app) + // .put('/assets') + // .set('Authorization', `Bearer ${stackUser.accessToken}`) + // .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + // expect(status).toBe(204); + + // const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + // expect(asset.stack).not.toBeUndefined(); + // expect(asset.stack).toEqual( + // expect.arrayContaining([ + // expect.objectContaining({ id: stackAssets[0].id }), + // expect.objectContaining({ id: stackAssets[1].id }), + // expect.objectContaining({ id: stackAssets[2].id }), + // ]), + // ); + // }); +}); diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts new file mode 100644 index 0000000000..a4cbc99ed3 --- /dev/null +++ b/e2e/src/api/specs/tag.e2e-spec.ts @@ -0,0 +1,603 @@ +import { + AssetMediaResponseDto, + LoginResponseDto, + Permission, + TagCreateDto, + TagResponseDto, + createTag, + getAllTags, + tagAssets, + upsertTags, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string, dto: TagCreateDto) => + createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) }); + +const upsert = (accessToken: string, tags: string[]) => + upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }); + +describe('/tags', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let userAsset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + userAsset = await utils.createAsset(user.accessToken); + }); + + beforeEach(async () => { + // tagging assets eventually triggers metadata extraction which can impact other tests + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.resetDatabase(['tags']); + }); + + describe('POST /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/tags').send({ name: 'TagA' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should work with tag.create', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a tag', async () => { + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should allow multiple users to create tags with the same value', async () => { + await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a nested tag', async () => { + const parent = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagB', parentId: parent.id }); + expect(body).toEqual({ + id: expect.any(String), + parentId: parent.id, + name: 'TagB', + value: 'TagA/TagB', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + }); + + describe('GET /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/tags'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).get('/tags').set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of tags', async () => { + const [tagA, tagB, tagC] = await Promise.all([ + create(admin.accessToken, { name: 'TagA' }), + create(admin.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + ]); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual([tagA, tagB, tagC]); + expect(status).toEqual(200); + }); + + it('should return a nested tags', async () => { + await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + + expect(body).toHaveLength(4); + expect(status).toEqual(200); + + const tags = body as TagResponseDto[]; + const tagA = tags.find((tag) => tag.value === 'TagA') as TagResponseDto; + const tagB = tags.find((tag) => tag.value === 'TagA/TagB') as TagResponseDto; + const tagC = tags.find((tag) => tag.value === 'TagA/TagB/TagC') as TagResponseDto; + const tagD = tags.find((tag) => tag.value === 'TagD') as TagResponseDto; + + expect(tagA).toEqual(expect.objectContaining({ name: 'TagA', value: 'TagA' })); + expect(tagB).toEqual(expect.objectContaining({ name: 'TagB', value: 'TagA/TagB', parentId: tagA.id })); + expect(tagC).toEqual(expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC', parentId: tagB.id })); + expect(tagD).toEqual(expect.objectContaining({ name: 'TagD', value: 'TagD' })); + }); + }); + + describe('PUT /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should upsert tags', async () => { + const { status, body } = await request(app) + .put(`/tags`) + .send({ tags: ['TagA/TagB/TagC/TagD'] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]); + }); + + it('should upsert tags in parallel without conflicts', async () => { + const [[tag1], [tag2], [tag3], [tag4]] = await Promise.all([ + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + upsert(admin.accessToken, ['TagA/TagB/TagC/TagD']), + ]); + + const { id, parentId, createdAt } = tag1; + for (const tag of [tag1, tag2, tag3, tag4]) { + expect(tag).toMatchObject({ + id, + parentId, + createdAt, + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + }); + } + }); + }); + + describe('PUT /tags/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put('/tags/assets') + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should skip assets that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(admin.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 3 }); + }); + + it('should skip tags that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 4 }); + }); + + it('should bulk tag assets', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 6 }); + }); + }); + + describe('GET /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .get(`/tags/${uuidDto.notFound}`) + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get tag details', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should get nested tag details', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id }); + const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id }); + + const { status, body } = await request(app) + .get(`/tags/${tagD.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + parentId: tagC.id, + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /tags/:id', () => { + it('should require authentication', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(admin.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .set('x-api-key', secret) + .send({ color: '#000000' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.update')); + }); + + it('should update a tag', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + + it('should update a tag color without a # prefix', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + }); + + describe('DELETE /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.delete')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete a tag', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + + it('should delete a nested tag (root)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagA.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(0); + }); + + it('should delete a nested tag (leaf)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagB.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(1); + expect(tags[0]).toEqual(tagA); + }); + }); + + describe('PUT /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to tag own asset', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it("should not be able to add assets to another user's tag", async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access')); + }); + + it('should add duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }), + ]); + }); + }); + + describe('DELETE /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tagA}/assets`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to remove own asset from own tag', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it('should remove duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }), + ]); + }); + }); +}); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 3049ff1511..0bfc0ec19b 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,10 +1,13 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; 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; @@ -34,13 +37,78 @@ describe('/trash', () => { const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); - const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); + const { status, body } = await request(app) + .post('/trash/empty') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should empty the trash with archived assets', async () => { + const { id: assetId } = await utils.createAsset(admin.accessToken); + await utils.archiveAssets(admin.accessToken, [assetId]); + await utils.deleteAssets(admin.accessToken, [assetId]); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true })); + + const { status, body } = await request(app) + .post('/trash/empty') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); + + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); + + const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); + expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should not delete offline-trashed assets from disk', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.items.length).toBe(1); + const asset = assets.items[0]; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true }); + + expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -59,12 +127,46 @@ describe('/trash', () => { const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); - const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); + const { status, body } = await request(app) + .post('/trash/restore') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); }); + + it('should not restore offline-trashed assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + }); }); describe('POST /trash/restore/assets', () => { @@ -82,14 +184,48 @@ describe('/trash', () => { const before = await utils.getAssetInfo(admin.accessToken, assetId); expect(before.isTrashed).toBe(true); - const { status } = await request(app) + const { status, body } = await request(app) .post('/trash/restore/assets') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ids: [assetId] }); - expect(status).toBe(204); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); + + it('should not restore an offline-trashed asset', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const after = await utils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); }); }); diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index b7147f52cc..8a417387e7 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -1,11 +1,11 @@ import { LoginResponseDto, + createStack, deleteUserAdmin, getMyUser, getUserAdmin, getUserPreferencesAdmin, login, - updateAssets, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; @@ -321,8 +321,8 @@ describe('/admin/users', () => { utils.createAsset(user.accessToken), ]); - await updateAssets( - { assetBulkUpdateDto: { stackParentId: asset1.id, ids: [asset2.id] } }, + await createStack( + { stackCreateDto: { assetIds: [asset1.id, asset2.id] } }, { headers: asBearerAuth(user.accessToken) }, ); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 15fe3de3be..1964dc6793 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -236,6 +236,32 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); + + it('should require a boolean for download include embedded videos', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: 1_234_567.89 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['download.includeEmbeddedVideos must be a boolean value'])); + }); + + it('should update download include embedded videos', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ download: { includeEmbeddedVideos: false } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { includeEmbeddedVideos: true } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { includeEmbeddedVideos: true } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } }); + }); }); describe('GET /users/:id', () => { diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index 0fb48188a2..3bc3ebc9c2 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -1,3 +1,4 @@ +import { Permission } from '@immich/sdk'; import { stat } from 'node:fs/promises'; import { app, immichCli, utils } from 'src/utils'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -29,10 +30,10 @@ describe(`immich login`, () => { it('should login and save auth.yml with 600', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken); + const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]); expect(stdout.split('\n')).toEqual([ - 'Logging in to http://127.0.0.1:2283/api', + 'Logging in to http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', 'Wrote auth info to /tmp/immich/auth.yml', ]); @@ -46,11 +47,11 @@ describe(`immich login`, () => { it('should login without /api in the url', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken); + const key = await utils.createApiKey(admin.accessToken, [Permission.All]); const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]); expect(stdout.split('\n')).toEqual([ - 'Logging in to http://127.0.0.1:2283', - 'Discovered API at http://127.0.0.1:2283/api', + 'Logging in to http://127.0.0.1:2285', + 'Discovered API at http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', 'Wrote auth info to /tmp/immich/auth.yml', ]); diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts index 13eefd3df4..96c45c8cc0 100644 --- a/e2e/src/cli/specs/server-info.e2e-spec.ts +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -12,7 +12,7 @@ describe(`immich server-info`, () => { const { stderr, stdout, exitCode } = await immichCli(['server-info']); expect(stdout.split('\n')).toEqual([ expect.stringContaining('Server Info (via admin@immich.cloud'), - ' Url: http://127.0.0.1:2283/api', + ' Url: http://127.0.0.1:2285/api', expect.stringContaining('Version:'), ' Formats:', expect.stringContaining('Images:'), diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts index db2b6c5341..d700aa73b2 100644 --- a/e2e/src/cli/specs/upload.e2e-spec.ts +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -1,9 +1,90 @@ import { LoginResponseDto, getAllAlbums, getAssetStatistics } from '@immich/sdk'; -import { readFileSync } from 'node:fs'; +import { cpSync, readFileSync } from 'node:fs'; import { mkdir, readdir, rm, symlink } from 'node:fs/promises'; -import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils'; +import { asKeyAuth, immichCli, specialCharStrings, testAssetDir, utils } from 'src/utils'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +interface Test { + test: string; + paths: string[]; + files: Record; +} + +const tests: Test[] = [ + { + test: 'should support globbing with *', + paths: [`/photos*`], + files: { + '/photos1/image1.jpg': true, + '/photos2/image2.jpg': true, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with an asterisk', + paths: [`/photos\*/image1.jpg`], + files: { + '/photos*/image1.jpg': true, + '/photos*/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a space', + paths: [`/my photos/image1.jpg`], + files: { + '/my photos/image1.jpg': true, + '/my photos/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a single quote', + paths: [`/photos\'/image1.jpg`], + files: { + "/photos'/image1.jpg": true, + "/photos'/image2.jpg": false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a double quote', + paths: [`/photos\"/image1.jpg`], + files: { + '/photos"/image1.jpg': true, + '/photos"/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a comma', + paths: [`/photos, eh/image1.jpg`], + files: { + '/photos, eh/image1.jpg': true, + '/photos, eh/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with an opening brace', + paths: [`/photos\{/image1.jpg`], + files: { + '/photos{/image1.jpg': true, + '/photos{/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a closing brace', + paths: [`/photos\}/image1.jpg`], + files: { + '/photos}/image1.jpg': true, + '/photos}/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, +]; + describe(`immich upload`, () => { let admin: LoginResponseDto; let key: string; @@ -32,6 +113,60 @@ describe(`immich upload`, () => { expect(assets.total).toBe(1); }); + describe(`should accept special cases`, () => { + for (const { test, paths, files } of tests) { + it(test, async () => { + const baseDir = `/tmp/upload/`; + + const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`); + testPaths.map((filePath) => utils.createImageFile(filePath)); + + const commandLine = paths.map((argument) => `${baseDir}/${argument}`); + + const expectedCount = Object.entries(files).filter((entry) => entry[1]).length; + + const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]); + expect(stderr).toBe(''); + expect(stdout.split('\n')).toEqual( + expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]), + ); + expect(exitCode).toBe(0); + + const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); + expect(assets.total).toBe(expectedCount); + + testPaths.map((filePath) => utils.removeImageFile(filePath)); + }); + } + }); + + it.each(specialCharStrings)(`should upload a multiple files from paths containing %s`, async (testString) => { + // https://github.com/immich-app/immich/issues/12078 + + // NOTE: this test must contain more than one path since a related bug is only triggered with multiple paths + + const testPaths = [ + `${testAssetDir}/temp/dir1${testString}name/asset.jpg`, + `${testAssetDir}/temp/dir2${testString}name/asset.jpg`, + ]; + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, testPaths[0]); + cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]); + + const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]); + expect(stderr).toBe(''); + expect(stdout.split('\n')).toEqual( + expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]), + ); + expect(exitCode).toBe(0); + + utils.removeImageFile(testPaths[0]); + utils.removeImageFile(testPaths[1]); + + const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); + expect(assets.total).toBe(2); + }); + it('should skip a duplicate file', async () => { const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); expect(first.stderr).toBe(''); diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 80e4f76f4f..0148f2e1e9 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -13,6 +13,12 @@ export const errorDto = { message: expect.any(String), correlationId: expect.any(String), }, + missingPermission: (permission: string) => ({ + error: 'Forbidden', + statusCode: 403, + message: `Missing required permission: ${permission}`, + correlationId: expect.any(String), + }), wrongPassword: { error: 'Bad Request', statusCode: 400, @@ -88,6 +94,7 @@ export const signupResponseDto = { quotaSizeInBytes: null, status: 'active', license: null, + profileChangedAt: expect.any(String), }, }; diff --git a/e2e/src/setup/auth-server.ts b/e2e/src/setup/auth-server.ts index a8c49050be..cde50813dd 100644 --- a/e2e/src/setup/auth-server.ts +++ b/e2e/src/setup/auth-server.ts @@ -50,6 +50,7 @@ const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || wi const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); + const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; const port = 3000; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -86,14 +87,14 @@ const setup = async () => { { client_id: OAuthClient.DEFAULT, client_secret: OAuthClient.DEFAULT, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], response_types: ['code'], }, { client_id: OAuthClient.RS256_TOKENS, client_secret: OAuthClient.RS256_TOKENS, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], id_token_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, @@ -101,7 +102,7 @@ const setup = async () => { { client_id: OAuthClient.RS256_PROFILE, client_secret: OAuthClient.RS256_PROFILE, - redirect_uris: ['http://127.0.0.1:2283/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], userinfo_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, diff --git a/e2e/src/setup/docker-compose.ts b/e2e/src/setup/docker-compose.ts index 3ae87417a2..49a702e776 100644 --- a/e2e/src/setup/docker-compose.ts +++ b/e2e/src/setup/docker-compose.ts @@ -12,7 +12,8 @@ const setup = async () => { const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); - const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); + const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans'; + const child = spawn('docker', command.split(' '), { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3acfc6f67c..3af44b50b8 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -7,6 +7,7 @@ import { CreateAlbumDto, CreateLibraryDto, MetadataSearchDto, + Permission, PersonCreateDto, SharedLinkCreateDto, UserAdminCreateDto, @@ -29,6 +30,7 @@ import { signUpAdmin, updateAdminOnboarding, updateAlbumUser, + updateAssets, updateConfig, validate, } from '@immich/sdk'; @@ -52,8 +54,8 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu type AdminSetupOptions = { onboarding?: boolean }; type FileData = { bytes?: Buffer; filename: string }; -const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich'; -export const baseUrl = 'http://127.0.0.1:2283'; +const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich'; +export const baseUrl = 'http://127.0.0.1:2285'; export const shareUrl = `${baseUrl}/share`; export const app = `${baseUrl}/api`; // TODO move test assets into e2e/assets @@ -66,6 +68,7 @@ export const immichCli = (args: string[]) => executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise; export const immichAdmin = (args: string[]) => executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); +export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; const executeCommand = (command: string, args: string[]) => { let _resolve: (value: CommandResponse) => void; @@ -147,14 +150,14 @@ export const utils = { 'sessions', 'users', 'system_metadata', + 'tags', ]; const sql: string[] = []; for (const table of tables) { if (table === 'system_metadata') { - // prevent reverse geocoder from being re-initialized - sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`); + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); } else { sql.push(`DELETE FROM ${table} CASCADE;`); } @@ -279,8 +282,8 @@ export const utils = { }); }, - createApiKey: (accessToken: string) => { - return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) }); + createApiKey: (accessToken: string, permissions: Permission[]) => { + return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) }); }, createAlbum: (accessToken: string, dto: CreateAlbumDto) => @@ -370,6 +373,12 @@ export const utils = { writeFileSync(path, makeRandomImage()); }, + createDirectory: (path: string) => { + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } + }, + removeImageFile: (path: string) => { if (!existsSync(path)) { return; @@ -378,6 +387,14 @@ export const utils = { rmSync(path); }, + removeDirectory: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path, { recursive: true }); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => @@ -387,6 +404,9 @@ export const utils = { return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) }); }, + archiveAssets: (accessToken: string, ids: string[]) => + updateAssets({ assetBulkUpdateDto: { ids, isArchived: true } }, { headers: asBearerAuth(accessToken) }), + deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), @@ -424,12 +444,12 @@ export const utils = { createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }), - setAuthCookies: async (context: BrowserContext, accessToken: string) => + setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => await context.addCookies([ { name: 'immich_access_token', value: accessToken, - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: true, @@ -439,7 +459,7 @@ export const utils = { { name: 'immich_auth_type', value: 'password', - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: true, @@ -449,7 +469,7 @@ export const utils = { { name: 'immich_is_authenticated', value: 'true', - domain: '127.0.0.1', + domain, path: '/', expires: 1_742_402_728, httpOnly: false, @@ -492,7 +512,7 @@ export const utils = { }, cliLogin: async (accessToken: string) => { - const key = await utils.createApiKey(accessToken); + const key = await utils.createApiKey(accessToken, [Permission.All]); await immichCli(['login', app, `${key.secret}`]); return key.secret; }, diff --git a/e2e/src/web/specs/album.e2e-spec.ts b/e2e/src/web/specs/album.e2e-spec.ts new file mode 100644 index 0000000000..953c7d00ae --- /dev/null +++ b/e2e/src/web/specs/album.e2e-spec.ts @@ -0,0 +1,25 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Album', () => { + let admin: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + }); + + test(`doesn't delete album after canceling add assets`, async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto('/albums'); + await page.getByRole('button', { name: 'Create album' }).click(); + await page.getByRole('button', { name: 'Select photos' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.reload(); + await page.getByRole('button', { name: 'Select photos' }).waitFor(); + }); +}); diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts index 072b48908e..2f90e4e3d8 100644 --- a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts @@ -1,16 +1,23 @@ import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; test.describe('Detail Panel', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test('can be opened for shared links', async ({ page }) => { @@ -57,4 +64,23 @@ test.describe('Detail Panel', () => { await expect(textarea).toBeVisible(); await expect(textarea).not.toBeDisabled(); }); + + test('description changes are visible after reopening', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + await page.goto(`/photos/${asset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + await page.getByRole('button', { name: 'Info' }).click(); + const textarea = page.getByRole('textbox', { name: 'Add a description' }); + await textarea.fill('new description'); + await expect(textarea).toHaveValue('new description'); + + await page.getByRole('button', { name: 'Info' }).click(); + await expect(textarea).not.toBeVisible(); + await page.getByRole('button', { name: 'Info' }).click(); + await expect(textarea).toBeVisible(); + + await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); + await expect(textarea).toHaveValue('new description'); + }); }); diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index b616a365cf..e89f17a4e9 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -33,6 +33,7 @@ test.describe('Registration', () => { // onboarding await expect(page).toHaveURL('/auth/onboarding'); await page.getByRole('button', { name: 'Theme' }).click(); + await page.getByRole('button', { name: 'Privacy' }).click(); await page.getByRole('button', { name: 'Storage Template' }).click(); await page.getByRole('button', { name: 'Done' }).click(); diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index f825b10315..09340e98cb 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -25,7 +25,7 @@ test.describe('Photo Viewer', () => { test('initially shows a loading spinner', async ({ page }) => { await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { - // slow down the request for thumbnail, so spiner has chance to show up + // slow down the request for thumbnail, so spinner has chance to show up await new Promise((f) => setTimeout(f, 2000)); await route.continue(); }); @@ -33,14 +33,14 @@ test.describe('Photo Viewer', () => { await page.waitForLoadState('load'); // this is the spinner await page.waitForSelector('svg[role=status]'); - await expect(page.getByRole('status')).toBeVisible(); + await expect(page.getByTestId('loading-spinner')).toBeVisible(); }); test('loads high resolution photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy; + expect(box).toBeTruthy(); const { x, y, width, height } = box!; await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.wheel(0, -1); diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index e40c20388b..2a02e429a5 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group > div').first().hover(); + await page.locator(`[data-asset-id="${asset.id}"]`).hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); @@ -69,4 +69,15 @@ test.describe('Shared Links', () => { await page.goto('/share/invalid'); await page.getByRole('heading', { name: 'Invalid share key' }).waitFor(); }); + + test('auth on navigation from shared link to timeline', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${sharedLink.key}`); + await page.getByRole('heading', { name: 'Test Album' }).waitFor(); + + await page.locator('a[href="/"]').click(); + await page.waitForURL('/photos'); + await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); + }); }); diff --git a/e2e/src/web/specs/websocket.e2e-spec.ts b/e2e/src/web/specs/websocket.e2e-spec.ts new file mode 100644 index 0000000000..a929c6467f --- /dev/null +++ b/e2e/src/web/specs/websocket.e2e-spec.ts @@ -0,0 +1,25 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Websocket', () => { + let admin: LoginResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + }); + + test('connects using ipv4', async ({ page, context }) => { + await utils.setAuthCookies(context, admin.accessToken); + await page.goto('http://127.0.0.1:2285/'); + await expect(page.locator('#sidebar')).toContainText('Server Online'); + }); + + test('connects using ipv6', async ({ page, context }) => { + await utils.setAuthCookies(context, admin.accessToken, '[::1]'); + await page.goto('http://[::1]:2285/'); + await expect(page.locator('#sidebar')).toContainText('Server Online'); + }); +}); diff --git a/e2e/test-assets b/e2e/test-assets index 898069e47f..3e057d2f58 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 +Subproject commit 3e057d2f58750acdf7ff281a3938e34a86cfef4d diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 500b6d3e59..9c80f25ace 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; // skip `docker compose up` if `make e2e` was already run const globalSetup: string[] = ['src/setup/auth-server.ts']; try { - await fetch('http://127.0.0.1:2283/api/server-info/ping'); + await fetch('http://127.0.0.1:2285/api/server-info/ping'); } catch { globalSetup.push('src/setup/docker-compose.ts'); } diff --git a/web/src/lib/i18n/af.json b/i18n/af.json similarity index 100% rename from web/src/lib/i18n/af.json rename to i18n/af.json diff --git a/web/src/lib/i18n/ar.json b/i18n/ar.json similarity index 85% rename from web/src/lib/i18n/ar.json rename to i18n/ar.json index b695784f80..7529ef82ca 100644 --- a/web/src/lib/i18n/ar.json +++ b/i18n/ar.json @@ -19,15 +19,16 @@ "add_more_users": "إضافة مستخدمين آخرين", "add_partner": "أضف شريكًا", "add_path": "إضافة مسار", - "add_photos": "إضافة صورة", + "add_photos": "إضافة صور", "add_to": "إضافة إلى…", "add_to_album": "إضافة إلى ألبوم", "add_to_shared_album": "إضافة إلى ألبوم مشترك", "added_to_archive": "أُضيفت للأرشيف", - "added_to_favorites": "أُضيفت للمفضلة", - "added_to_favorites_count": "تم إضافة {count} إلى المفضلة", + "added_to_favorites": "أُضيفت للمفضلات", + "added_to_favorites_count": "تم إضافة {count, number} إلى المفضلات", "admin": { "add_exclusion_pattern_description": "إضافة أنماط الاستبعاد. يدعم التمويه باستخدام *، **، و؟. لتجاهل جميع الملفات في أي دليل يسمى \"Raw\"، استخدم \"**/Raw/**\". لتجاهل جميع الملفات التي تنتهي بـ \".tif\"، استخدم \"**/*.tif\". لتجاهل مسار مطلق، استخدم \"/path/to/ignore/**\".", + "asset_offline_description": "لم يعد هذا الأصل الخاص بالمكتبة الخارجية موجودًا على القرص وتم نقله إلى سلة المهملات. إذا تم نقل الملف داخل المكتبة، فتحقق من الجدول الزمني الخاص بك لمعرفة الأصل الجديد المقابل. لاستعادة هذا الأصل، يرجى التأكد من إمكانية الوصول إلى مسار الملف أدناه بواسطة Immich ومن ثم قم بمسح المكتبة.", "authentication_settings": "إعدادات المصادقة", "authentication_settings_description": "إدارة كلمة المرور وOAuth وإعدادات المصادقة الأُخرى", "authentication_settings_disable_all": "هل أنت متأكد أنك تريد تعطيل جميع وسائل تسجيل الدخول؟ سيتم تعطيل تسجيل الدخول بالكامل.", @@ -41,6 +42,7 @@ "confirm_email_below": "للتأكيد، اكتب \"{email}\" بالأسفل", "confirm_reprocess_all_faces": "هل أنت متأكد أنك تريد إعادة معالجة جميع الوجوه؟ سيخلي هذا كل الأشخاص الذين سَميتَهم.", "confirm_user_password_reset": "هل أنت متأكد أنك تريد إعادة تعيين كلمة مرور {user}؟", + "create_job": "إنشاء وظيفة", "crontab_guru": "", "disable_login": "تعطيل تسجيل الدخول", "disabled": "", @@ -48,28 +50,38 @@ "exclusion_pattern_description": "تتيح لك أنماط الاستبعاد تجاهل الملفات والمجلدات عند فحص مكتبتك. يعد هذا مفيدًا إذا كان لديك مجلدات تحتوي على ملفات لا تريد استيرادها، مثل ملفات RAW.", "external_library_created_at": "مكتبة خارجية (أُنشئت في {date})", "external_library_management": "إدارة المكتبة الخارجية", - "face_detection": "اكتشاف الوجوه", - "face_detection_description": "اكتشف الوجوه في المحتويات باستخدام التعلم الآلي. بالنسبة للفيديوهات، سيتم فقط استخدام الصورة المصغرة. خيار \"الكل\" يعيد معالجة كل المحتويات. خيار \"مفقود\" يضع في قائمة الإنتظار المحتويات التي لم تعالج بعد. سيتم وضع الوجوه المكتشفة في قائمة إنتظار التعرف على الوجه بعد اكتمال اكتشاف الوجه، مما يجمعها بأشخاص موجودين أو جدد.", - "facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"الكل\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.", + "face_detection": "إ‏كتشاف الوجوه", + "face_detection_description": "اكتشف الوجوه في الأصول باستخدام التعلم الآلي. بالنسبة لمقاطع الفيديو، يتم اعتبار الصورة المصغرة فقط. \"تحديث\" (إعادة) معالجة جميع الأصول. \"إعادة تعيين\" تمسح أيضًا جميع بيانات الوجوه الحالية. \"مفقود\" يضع الأصول التي لم تتم معالجتها بعد في قائمة الانتظار. سيتم وضع الوجوه المكتشفة في قائمة الانتظار للتعرف على الوجه بعد اكتمال اكتشاف الوجه، وتجميعها في أشخاص موجودين أو جدد.", + "facial_recognition_job_description": "تجميع الوجوه المكتشفة كأشخاص. يتم تنفيذ هذه الخطوة بعد اكتمال اكتشاف الوجه. خيار \"إعادة التعيين\" يعيد تجميع جميع الوجوه. خيار \"المفقود\" يضع في قائمة الانتظار الوجوه التي لم يتم تعيين شخص لها.", "failed_job_command": "فشل الأمر {command} للمهمة: {job}", "force_delete_user_warning": "تحذير: سيؤدي ذلك إلى إزالة المستخدم وجميع محتوياته على الفور. لا يمكن التراجع عن هذا الإجراء ولا يمكن استرداد الملفات.", "forcing_refresh_library_files": "إجبار التحديث لجميع ملفات المكتبة", + "image_format": "التنسيق", "image_format_description": "يُنتج WebP ملفات أصغر حجمًا من ملفات JPEG، ولكنه أبطأ في عملية الترميز.", "image_prefer_embedded_preview": "تفضيل المعاينة المدمجة", "image_prefer_embedded_preview_setting_description": "استخدم المعاينات المضمنة في صور RAW كمدخل لمعالجة الصور عندما تكون متاحة. يؤدي لإنتاج ألوان أكثر دقة لبعض الصور، لكن جودة المعاينة تعتمد على الكاميرا وقد تحتوي الصورة على شوائب ضغطٍ أكثر.", "image_prefer_wide_gamut": "تفضيل نطاق الألوان الواسع", "image_prefer_wide_gamut_setting_description": "استخدم Display P3 للصور المصغرة. يحافظ هذا على حيوية الصور ذات مساحات الألوان الواسعة بشكل أفضل، ولكن قد تظهر الصور بشكل مختلف على الأجهزة القديمة ذات إصدار متصفح قديم. يتم الاحتفاظ بصور sRGB بتنسيق sRGB لتجنب تغيرات اللون.", + "image_preview_description": "صورة متوسطة الحجم مع بيانات وصفية مجردة، تُستخدم عند عرض أصل واحد وللتعلم الآلي", "image_preview_format": "تنسيق المعاينة", + "image_preview_quality_description": "جودة المعاينة من 1 إلى 100. كلما كانت القيمة أعلى كان ذلك أفضل، ولكنها تنتج ملفات أكبر وقد تقلل من استجابة التطبيق. قد يؤثر ضبط قيمة منخفضة على جودة التعلم الآلي.", "image_preview_resolution": "معاينة الدقّة", "image_preview_resolution_description": "يُستخدم عند عرض صورة واحدة وللتعلم الآلي. ستحافظ الدقاتُ العالية على المزيد من التفاصيل ولكنها ستستغرق وقتًا أطول للترميز، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.", + "image_preview_title": "إعدادات المعاينة", "image_quality": "الجودة", "image_quality_description": "جودة الصورة من 1-100. الأعلى هو الأفضل من حيث الجودة ولكنه ينتج ملفات أكبر، ويؤثر هذا الخيار على صور المعاينة والصور المصغرة.", + "image_resolution": "الدقة", + "image_resolution_description": "يمكن للدقة العالية الحفاظ على مزيد من التفاصيل ولكنها تستغرق وقتًا أطول للترميز، وتحتوي على أحجام ملفات أكبر ويمكن أن تقلل من استجابة التطبيق.", "image_settings": "إعدادات الصور", "image_settings_description": "إدارة جودة ودقة الصور التي تم إنشاؤها", + "image_thumbnail_description": "صورة مصغرة صغيرة مع بيانات وصفية مجردة، تُستخدم عند عرض مجموعات من الصور مثل الجدول الزمني الرئيسي", "image_thumbnail_format": "تنسيق الصور المصغّرة", + "image_thumbnail_quality_description": "تتراوح جودة الصورة المصغرة من 1 إلى 100. كلما كانت الجودة أعلى كان ذلك أفضل، ولكنها تنتج ملفات أكبر وقد تقلل من استجابة التطبيق.", "image_thumbnail_resolution": "دقة الصور المصغّرة", "image_thumbnail_resolution_description": "يُستخدم عند عرض مجموعات من الصور (المخطط الزمني الرئيسي، عرض الألبوم، وما إلى ذلك). ستحافظ الدقاتُ العالية على المزيد من التفاصيل ولكنها ستستغرق وقتًا أطول للترميز، ولها أحجام ملفات أكبر، ويمكن أن تقلل من استجابة التطبيق.", + "image_thumbnail_title": "إعدادات الصورة المصغرة", "job_concurrency": "تزامن {job}", + "job_created": "تم إنشاء الوظيفة", "job_not_concurrency_safe": "هذه الوظيفة غير آمنة للتشغيل المتزامن.", "job_settings": "إعدادات الوظائف", "job_settings_description": "إدارة تزامن الوظائف", @@ -103,7 +115,7 @@ "machine_learning_enabled": "تفعيل التعلم الآلي", "machine_learning_enabled_description": "إذا تم تعطيله، سيتم تعطيل جميع ميزات التعلم الآلي بغض النظر عن الإعدادات أدناه.", "machine_learning_facial_recognition": "التعرف على الوجوه", - "machine_learning_facial_recognition_description": "الاكتشاف، والتعرف، وتجميع الوجوه في الصور", + "machine_learning_facial_recognition_description": "الاكتشاف، التعرف على، وتجميع الوجوه في الصور", "machine_learning_facial_recognition_model": "نموذج التعرف على الوجوه", "machine_learning_facial_recognition_model_description": "النماذج مدرجة بترتيب تنازلي حسب الحجم. النماذج الأكبر حجماً أبطأ وتستخدم ذاكرة أكثر، ولكنها تنتج نتائج أفضل. يرجى ملاحظة أنه يجب إعادة تشغيل وظيفة الكشف عن الوجوه لجميع الصور بعد تغيير النموذج.", "machine_learning_facial_recognition_setting": "تفعيل التعرف على الوجوه", @@ -129,16 +141,21 @@ "map_enable_description": "تفعيل ميزات الخرائط", "map_gps_settings": "إعدادات الخريطة ونظام تحديد المواقع", "map_gps_settings_description": "إدارة إعدادات الخريطة و نظام تحديد المواقع (عكس الترميز الجغرافي)", + "map_implications": "تعتمد ميزة الخريطة على خدمة خارجية (tiles.immich.cloud)", "map_light_style": "النمط الفاتح", "map_manage_reverse_geocoding_settings": "إدارة إعدادات التكوين الجغرافي المعكوس", "map_reverse_geocoding": "عكس الترميز الجغرافي", "map_reverse_geocoding_enable_description": "تفعيل عكس الترميز الجغرافي", "map_reverse_geocoding_settings": "إعدادات عكس الترميز الجغرافي", - "map_settings": "إعدادات الخريطة", + "map_settings": "الخريطة", "map_settings_description": "إدارة إعدادات الخريطة", "map_style_description": "عنوان URL لسمة الخريطة style.json", "metadata_extraction_job": "استخراج البيانات الوصفية", - "metadata_extraction_job_description": "استخراج معلومات البيانات الوصفية من كل أصل، مثل إحداثيات الموقع والدقة", + "metadata_extraction_job_description": "استخراج معلومات البيانات الوصفية من كل أصل، مثل إحداثيات الموقع, الوجوه والدقة", + "metadata_faces_import_setting": "تمكين استيراد الوجه", + "metadata_faces_import_setting_description": "استيراد الوجوه من بيانات EXIF للصور وملفات Sidecar", + "metadata_settings": "إعدادات البيانات الوصفية", + "metadata_settings_description": "إدارة إعدادات البيانات الوصفية", "migration_job": "ترحيل", "migration_job_description": "ترحيل الصور المصغرة للمحتويات والوجوه إلى أحدث هيكل مجلدات", "no_paths_added": "لم يتم إضافة أي مسارات", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "ملاحظة: لا يمكن تغيير هذا لاحقًا!", "note_unlimited_quota": "ملاحظة: أدخل 0 للحصول على حصة غير محدودة", "notification_email_from_address": "عنوان المرسل", - "notification_email_from_address_description": "عنوان البريد الإلكتروني للمرسل، على سبيل المثال: \"Immich Photo Server noreply@immich.app\"", + "notification_email_from_address_description": "عنوان البريد الإلكتروني للمرسل، على سبيل المثال: \"Immich Photo Server noreply@example.com\"", "notification_email_host_description": "مضيف خادم البريد الإلكتروني (مثلًا: smtp.immich.app)", "notification_email_ignore_certificate_errors": "تجاهل أخطاء الشهادة", "notification_email_ignore_certificate_errors_description": "تجاهل أخطاء التحقق من صحة شهادة TLS (غير مستحسن)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "عنوان URL الخاص بجهة الإصدار", "oauth_mobile_redirect_uri": "عنوان URI لإعادة التوجيه على الهاتف", "oauth_mobile_redirect_uri_override": "تجاوز عنوان URI لإعادة التوجيه على الهاتف", - "oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما يكون عنوان URI إعادة التوجيه 'app.immich:/' غير صالح.", + "oauth_mobile_redirect_uri_override_description": "قم بتفعيله عندما لا يسمح موفر OAuth بمعرف URI للجوال، مثل '{callback}'", "oauth_profile_signing_algorithm": "خوارزمية توقيع الملف الشخصي", "oauth_profile_signing_algorithm_description": "الخوارزمية المستخدمة للتوقيع على ملف تعريف المستخدم.", "oauth_scope": "النطاق", @@ -193,19 +210,22 @@ "password_settings": "تسجيل الدخول بكلمة المرور", "password_settings_description": "إدارة تسجيل الدخول بكلمة المرور", "paths_validated_successfully": "تم التحقق من صحة كافة المسارات بنجاح", + "person_cleanup_job": "تنظيف الشخص", "quota_size_gib": "حجم الحصة (جيجابايت)", "refreshing_all_libraries": "تحديث كافة المكتبات", "registration": "تسجيل المدير", "registration_description": "بما أنك أول مستخدم في النظام، سيتم تعيينك كمسؤول وستكون مسؤولًا عن المهام الإدارية، وسيتم إنشاء مستخدمين إضافيين بواسطتك.", - "removing_offline_files": "إزالة الملفات غير المتصلة", + "removing_deleted_files": "إزالة الملفات غير المتصلة", "repair_all": "إصلاح الكل", "repair_matched_items": "تمت مطابقة {count, plural, one {# عنصر} other {# عناصر}}", "repaired_items": "تم إصلاح {count, plural, one {# عنصر} other {# عناصر}}", "require_password_change_on_login": "الطلب من المستخدم تغيير كلمة المرور عند تسجيل الدخول الأول", "reset_settings_to_default": "إعادة ضبط الإعدادات إلى الوضع الافتراضي", "reset_settings_to_recent_saved": "إعادة ضبط الإعدادات إلى الإعدادات المحفوظة مؤخرًا", + "scanning_library": "مسح المكتبة", "scanning_library_for_changed_files": "فحص المكتبة لاكتشاف الملفات التي تم تغييرها", "scanning_library_for_new_files": "فحص المكتبة للبحث عن ملفات جديدة", + "search_jobs": "البحث عن وظائف...", "send_welcome_email": "إرسال بريد ترحيبي", "server_external_domain_settings": "إسم النطاق الخارجي", "server_external_domain_settings_description": "إسم النطاق لروابط المشاركة العامة، بما في ذلك http(s)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "إدارة هيكل المجلد واسم الملف للأصول المرفوعة", "storage_template_user_label": "{label} هو تسمية التخزين الخاصة بالمستخدم", "system_settings": "إعدادات النظام", + "tag_cleanup_job": "تنظيف العلامة", "theme_custom_css_settings": "CSS مخصص", "theme_custom_css_settings_description": "أوراق الأنماط المتتالية تسمح بتخصيص تصميم Immich.", "theme_settings": "إعدادات السمة", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "الجهاز المفضل", "transcoding_preferred_hardware_device_description": "ينطبق فقط على VAAPI وQSV. يضبط عقدة dri المستخدمة لتحويل ترميز الأجهزة.", "transcoding_preset_preset": "الضبط المُسبق (-preset)", - "transcoding_preset_preset_description": "سرعة الضغط. تؤدي الإعدادات المسبقة الأبطأ إلى إنتاج ملفات أصغر حجمًا، وزيادة الجودة عند استهداف معدل بت معين. يتجاهل VP9 السرعات الأعلى من \"الأسرع\".", + "transcoding_preset_preset_description": "سرعة الضغط. تؤدي الإعدادات المسبقة الأبطأ إلى إنتاج ملفات أصغر حجمًا، وزيادة الجودة عند استهداف معدل بت معين. يتجاهل VP9 السرعات الأعلى من 'الأسرع'.", "transcoding_reference_frames": "الإطارات المرجعية", "transcoding_reference_frames_description": "عدد الإطارات التي يجب الرجوع إليها عند ضغط إطار معين. تعمل القيم الأعلى على تحسين كفاءة الضغط، ولكنها تبطئ عملية التشفير. 0 يضبط هذه القيمة تلقائيًا.", "transcoding_required_description": "فقط مقاطع الفيديو ذات التنسيق غير المقبول", @@ -307,6 +328,7 @@ "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": "عدد الأيام بعد الإزالة لحذف حساب المستخدم ومحتوياته بشكل دائم. تقوم وظيفة حذف المستخدم بالتشغيل في منتصف الليل للتحقق من المستخدمين الجاهزين للحذف. سيتم تقييم التغييرات على هذا الإعداد في التنفيذ القادم.", @@ -320,7 +342,8 @@ "user_settings": "إعدادات المستخدم", "user_settings_description": "إدارة إعدادات المستخدم", "user_successfully_removed": "تمت إزالة المستخدم {email} بنجاح.", - "version_check_enabled_description": "تفعيل إرسال طلبات دورية إلى GitHub للتحقق من الإصدارات الجديدة", + "version_check_enabled_description": "تفعيل التحقق من الإصدارات الجديدة", + "version_check_implications": "تعتمد ميزة التحقق من الإصدار على التواصل الدوري مع github.com", "version_check_settings": "التحقق من الإصدار", "version_check_settings_description": "تفعيل/تعطيل الإشعار لإصدار جديد", "video_conversion_job": "تحويل أشرطة الفيديو", @@ -336,7 +359,8 @@ "album_added": "تمت إضافة الألبوم", "album_added_notification_setting_description": "تلقي إشعارًا بالبريد الإلكتروني عند إضافتك إلى ألبوم مشترك", "album_cover_updated": "تم تحديث غلاف الألبوم", - "album_delete_confirmation": "هل أنت متأكد أنك تريد حذف الألبوم {album}؟\nإذا تمت مشاركة هذا الألبوم، فلن يتمكن المستخدمون الآخرون من الوصول إليه بعد الآن.", + "album_delete_confirmation": "هل أنت متأكد أنك تريد حذف الألبوم {album}؟", + "album_delete_confirmation_description": "إذا تمت مشاركة هذا الألبوم، فلن يتمكن المستخدمون الآخرون من الوصول إليه بعد الآن.", "album_info_updated": "تم تحديث معلومات الألبوم", "album_leave": "هل تريد مغادرة الألبوم؟", "album_leave_confirmation": "هل أنت متأكد أنك تريد مغادرة {album}؟", @@ -360,6 +384,7 @@ "allow_edits": "إسمح بالتعديل", "allow_public_user_to_download": "السماح لأي مستخدم عام بالتنزيل", "allow_public_user_to_upload": "السماح للمستخدم العام بالرفع", + "anti_clockwise": "عكس اتجاه عقارب الساعة", "api_key": "مفتاح واجهة برمجة التطبيقات", "api_key_description": "سيتم عرض هذه القيمة مرة واحدة فقط. يرجى التأكد من نسخها قبل إغلاق النافذة.", "api_key_empty": "يجب ألا يكون اسم مفتاح API فارغًا", @@ -380,9 +405,10 @@ "asset_filename_is_offline": "الأصل {filename} غير متصل", "asset_has_unassigned_faces": "يحتوي الأصل على وجوه غير مخصصة", "asset_hashing": "التجزئة...", - "asset_offline": "المحتوى دون اتصال", - "asset_offline_description": "هذا الأصل غير متصل. لا يستطيع Immic الوصول إلى موقع الملف الخاص به. يرجى التأكد من توفر الأصل ثم إعادة فحص المكتبة.", + "asset_offline": "المحتوى غير اتصال", + "asset_offline_description": "لم يعد هذا الأصل الخارجي موجودًا على القرص. يرجى الاتصال بمسؤول Immich للحصول على المساعدة.", "asset_skipped": "تم تخطيه", + "asset_skipped_in_trash": "في سلة المهملات", "asset_uploaded": "تم الرفع", "asset_uploading": "جارٍ الرفع...", "assets": "المحتويات", @@ -393,7 +419,7 @@ "assets_moved_to_trash_count": "تم نقل {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات", "assets_permanently_deleted_count": "تم حذف {count, plural, one {# هذا المحتوى} other {# هذه المحتويات}} بشكل دائم", "assets_removed_count": "تمت إزالة {count, plural, one {# محتوى} other {# محتويات}}", - "assets_restore_confirmation": "هل أنت متأكد أنك تريد استعادة كافة المحتويات المحذوفة؟ لا يمكنك التراجع عن هذا الإجراء!", + "assets_restore_confirmation": "هل أنت متأكد من أنك تريد استعادة جميع الأصول المحذوفة؟ لا يمكنك التراجع عن هذا الإجراء! لاحظ أنه لا يمكن استعادة أي أصول غير متصلة بهذه الطريقة.", "assets_restored_count": "تمت استعادة {count, plural, one {# محتوى} other {# محتويات}}", "assets_trashed_count": "تم إرسال {count, plural, one {# محتوى} other {# محتويات}} إلى سلة المهملات", "assets_were_part_of_album_count": "{count, plural, one {هذا المحتوى} other {هذه المحتويات}} في الألبوم بالفعل", @@ -404,6 +430,7 @@ "birthdate_saved": "تم حفظ تاريخ الميلاد بنجاح", "birthdate_set_description": "يتم استخدام تاريخ الميلاد لحساب عمر هذا الشخص وقت التقاط الصورة.", "blurred_background": "خلفية مشوشة", + "bugs_and_feature_requests": "الأخطاء وطلبات الميزات", "build": "يبني", "build_image": "بناء الصورة", "bulk_delete_duplicates_confirmation": "هل أنت متأكد من أنك تريد حذف {count, plural, one {# محتوى مكرر} other {# محتويات مكررة}} بالجملة؟ سيحتفظ هذا بأكبر محتوى من كل مجموعة ويحذف جميع النسخ المكررة الأخرى بشكل دائم. لا يمكنك التراجع عن هذا الإجراء!", @@ -440,9 +467,11 @@ "clear_all_recent_searches": "مسح جميع عمليات البحث الأخيرة", "clear_message": "إخلاء الرسالة", "clear_value": "إخلاء القيمة", + "clockwise": "باتجاه عقارب الساعة", "close": "إغلاق", "collapse": "طي", "collapse_all": "طيّ الكل", + "color": "اللون", "color_theme": "نمط الألوان", "comment_deleted": "تم حذف التعليق", "comment_options": "خيارات التعليق", @@ -476,6 +505,8 @@ "create_new_person": "إنشاء شخص جديد", "create_new_person_hint": "تعيين المحتويات المحددة لشخص جديد", "create_new_user": "إنشاء مستخدم جديد", + "create_tag": "إنشاء علامة", + "create_tag_description": "أنشئ علامة جديدة. بالنسبة للعلامات المتداخلة، يرجى إدخال المسار الكامل للعلامة بما في ذلك الخطوط المائلة للأمام.", "create_user": "إنشاء مستخدم", "created": "تم الإنشاء", "current_device": "الجهاز الحالي", @@ -499,13 +530,17 @@ "delete_library": "حذف المكتبة", "delete_link": "حذف الرابط", "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": "تجاهل الخطأ", @@ -514,8 +549,11 @@ "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": "جارٍ التنزيل", @@ -545,10 +583,15 @@ "edit_location": "تعديل الموقع", "edit_name": "تعديل الاسم", "edit_people": "تعديل الأشخاص", + "edit_tag": "تعديل العلامة", "edit_title": "تعديل العنوان", "edit_user": "تعديل المستخدم", "edited": "تم التعديل", - "editor": "", + "editor": "محرر", + "editor_close_without_save_prompt": "لن يتم حفظ التغييرات", + "editor_close_without_save_title": "إغلاق المحرر؟", + "editor_crop_tool_h2_aspect_ratios": "نسب العرض إلى الارتفاع", + "editor_crop_tool_h2_rotation": "التدوير", "email": "البريد الإلكتروني", "empty": "", "empty_album": "", @@ -588,6 +631,7 @@ "failed_to_load_asset": "فشل تحميل المحتوى", "failed_to_load_assets": "فشل تحميل المحتويات", "failed_to_load_people": "فشل تحميل الأشخاص", + "failed_to_remove_product_key": "تعذر إزالة مفتاح المنتج", "failed_to_stack_assets": "فشل في تكديس المحتويات", "failed_to_unstack_assets": "فشل في فصل المحتويات", "import_path_already_exists": "مسار الاستيراد هذا موجود مسبقًا.", @@ -637,6 +681,7 @@ "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": "غير قادر على تحميل نشاط المحتويات", @@ -653,8 +698,8 @@ "unable_to_remove_api_key": "تعذر إزالة مفتاح API", "unable_to_remove_assets_from_shared_link": "غير قادر على إزالة المحتويات من الرابط المشترك", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "غير قادر على إزالة الملفات غير المتصلة", "unable_to_remove_library": "غير قادر على إزالة المكتبة", - "unable_to_remove_offline_files": "غير قادر على إزالة الملفات غير المتصلة", "unable_to_remove_partner": "غير قادر على إزالة الشريك", "unable_to_remove_reaction": "غير قادر على إزالة رد الفعل", "unable_to_remove_user": "", @@ -677,6 +722,7 @@ "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": "غير قادر على تحديث المكتبة", @@ -697,6 +743,7 @@ "expired": "منتهي الصلاحية", "expires_date": "تنتهي الصلاحية في {date}", "explore": "استكشاف", + "explorer": "المستكشف", "export": "تصدير", "export_as_json": "تصدير كـ JSON", "extension": "الإمتداد", @@ -710,6 +757,8 @@ "feature": "", "feature_photo_updated": "تم تحديث الصورة المميزة", "featurecollection": "", + "features": "الميزات", + "features_setting_description": "إدارة ميزات التطبيق", "file_name": "إسم الملف", "file_name_or_extension": "اسم الملف أو امتداده", "filename": "اسم الملف", @@ -718,6 +767,8 @@ "filter_people": "تصفية الاشخاص", "find_them_fast": "يمكنك العثور عليها بسرعة بالاسم من خلال البحث", "fix_incorrect_match": "إصلاح المطابقة غير الصحيحة", + "folders": "المجلدات", + "folders_feature_description": "تصفح عرض المجلد للصور ومقاطع الفيديو الموجودة على نظام الملفات", "force_re-scan_library_files": "فرض إعادة فحص جميع ملفات المكتبة", "forward": "إلى الأمام", "general": "عام", @@ -741,7 +792,16 @@ "host": "المضيف", "hour": "ساعة", "image": "صورة", - "image_alt_text_date": "في {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} تم التقاطها في {date}", + "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} with {person1}, {person2}, مع {additionalCount, number} آخرين في {date}", "image_alt_text_people": "{count, plural, =1 {مع {person1}} =2 {مع {person1} و {person2}} =3 {مع {person1} و {person2} و {person3}} other {مع {person1} و {person2} و {others, number} آخرين}}", "image_alt_text_place": "في {city}, {country}", "image_taken": "{isVideo, select, true {تم التقاط الفيديو} other {تم التقاط الصورة}}", @@ -808,6 +868,7 @@ "license_trial_info_4": "يُرجى التفكير في شراء رخصة لدعم التطوير المستمر للخدمة", "light": "المضيئ", "like_deleted": "تم حذف الإعجاب", + "link_motion_video": "رابط فيديو الحركة", "link_options": "خيارات الرابط", "link_to_oauth": "الربط مع OAuth", "linked_oauth_account": "حساب مرتبط بـ OAuth", @@ -847,7 +908,7 @@ "menu": "القائمة", "merge": "الدمج", "merge_people": "دمج الأشخاص", - "merge_people_limit": "يمكنك دمج ما يصل إلى 5 وجوه فقط في المرة الواحدة", + "merge_people_limit": "يمكنك دمج حتى 5 وجوه فقط في المرة الواحدة", "merge_people_prompt": "هل تريد دمج هؤلاء الناس؟ هذا الإجراء لا رجعة فيه.", "merge_people_successfully": "تم دمج الأشخاص بنجاح", "merged_people_count": "دمج {count, plural, one {شخص واحد} other {# أشخاص}}", @@ -862,6 +923,7 @@ "name": "الاسم", "name_or_nickname": "الاسم أو اللقب", "never": "أبداً", + "new_album": "البوم جديد", "new_api_key": "مفتاح API جديد", "new_password": "كلمة المرور الجديدة", "new_person": "شخص جديد", @@ -894,18 +956,21 @@ "notifications": "إشعارات", "notifications_setting_description": "إدارة الإشعارات", "oauth": "OAuth", + "official_immich_resources": "الموارد الرسمية لشركة Immich", "offline": "غير متصل", "offline_paths": "مسارات غير متصلة", "offline_paths_description": "قد تكون هذه النتائج بسبب الحذف اليدوي للملفات التي لا تشكل جزءًا من مكتبة خارجية.", "ok": "نعم", "oldest_first": "الأقدم أولا", "onboarding": "الإعداد الأولي", + "onboarding_privacy_description": "تعتمد الميزات التالية (اختياري) على خدمات خارجية، ويمكن تعطيلها في أي وقت في إعدادات الإدارة.", "onboarding_theme_description": "اختر نسق الألوان للنسخة الخاصة بك. يمكنك تغيير ذلك لاحقًا في إعداداتك.", "onboarding_welcome_description": "لنقم بإعداد نسختك باستخدام بعض الإعدادات الشائعة.", "onboarding_welcome_user": "مرحبا، {user}", "online": "متصل", "only_favorites": "المفضلة فقط", "only_refreshes_modified_files": "تحديث الملفات المعدلة فقط", + "open_in_map_view": "فتح في عرض الخريطة", "open_in_openstreetmap": "فتح في OpenStreetMap", "open_the_search_filters": "افتح مرشحات البحث", "options": "خيارات", @@ -940,6 +1005,7 @@ "pending": "قيد الانتظار", "people": "الأشخاص", "people_edits_count": "تم تعديل {count, plural, one {# شخص } other {# أشخاص }}", + "people_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب الأشخاص", "people_sidebar_description": "عرض رابط للأشخاص في الشريط الجانبي", "perform_library_tasks": "", "permanent_deletion_warning": "تحذير الحذف الدائم", @@ -971,11 +1037,48 @@ "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 والبرمجيات مفتوحة المصدر", + "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": "يتم إدارة مفتاح منتج الخادم من قبل مدير النظام", "range": "", + "rating": "تقييم نجمي", + "rating_clear": "مسح التقييم", + "rating_count": "{count, plural, one {# نجمة} other {# نجوم}}", + "rating_description": "‫‌اعرض تقييم EXIF في لوحة المعلومات", "raw": "", "reaction_options": "خيارات رد الفعل", "read_changelog": "قراءة سجل التغيير", @@ -987,11 +1090,13 @@ "recent_searches": "عمليات البحث الأخيرة", "refresh": "تحديث", "refresh_encoded_videos": "تحديث مقاطع الفيديو المشفرة", + "refresh_faces": "تحديث الوجوه", "refresh_metadata": "تحديث البيانات الوصفية", "refresh_thumbnails": "تحديث الصور المصغرة", "refreshed": "تم التحديث", - "refreshes_every_file": "تحديث جميع الملفات", + "refreshes_every_file": "إعادة قراءة كافة الملفات الموجودة والجديدة", "refreshing_encoded_video": "جارٍ تحديث الفيديو المرمز", + "refreshing_faces": "جاري تحديث الوجوه", "refreshing_metadata": "جارٍ تحديث البيانات الوصفية", "regenerating_thumbnails": "جارٍ تجديد الصور المصغرة", "remove": "إزالة", @@ -999,15 +1104,16 @@ "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": "إزالة من الرابط المشترك", - "remove_offline_files": "إزالة الملفات الغير متصلة", "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": "ستظهر هنا الملفات المفقودة وأيضاً التي لم يتم تعقبها", @@ -1020,6 +1126,7 @@ "reset_people_visibility": "إعادة ضبط ظهور الأشخاص", "reset_settings_to_default": "", "reset_to_default": "إعادة التعيين إلى الافتراضي", + "resolve_duplicates": "معالجة النسخ المكررة", "resolved_all_duplicates": "تم حل جميع التكرارات", "restore": "الاستعاده من سلة المهملات", "restore_all": "استعادة الكل", @@ -1038,6 +1145,7 @@ "say_something": "قل شيئًا", "scan_all_libraries": "فحص كل المكتبات", "scan_all_library_files": "إعادة فحص كافة ملفات المكتبة", + "scan_library": "مسح", "scan_new_library_files": "فحص ملفات المكتبة الجديدة", "scan_settings": "إعدادات الفحص", "scanning_for_album": "جارٍ الفحص عن ألبوم...", @@ -1053,9 +1161,12 @@ "search_for_existing_person": "البحث عن شخص موجود", "search_no_people": "لا يوجد أشخاص", "search_no_people_named": "لا يوجد أشخاص بالاسم \"{name}\"", + "search_options": "خيارات البحث", "search_people": "البحث عن الأشخاص", "search_places": "البحث عن الأماكن", + "search_settings": "إعدادات البحث", "search_state": "البحث حسب الولاية...", + "search_tags": "البحث عن العلامات...", "search_timezone": "البحث حسب المنطقة الزمنية...", "search_type": "نوع البحث", "search_your_photos": "ابحث عن صورك", @@ -1064,13 +1175,14 @@ "see_all_people": "عرض جميع الأشخاص", "select_album_cover": "حدد غلاف الألبوم", "select_all": "تحديد الكل", - "select_avatar_color": "حدد اللون الرمزي", - "select_face": "حدد الوجه", + "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": "المُحدّد", @@ -1096,6 +1208,7 @@ "shared_by_user": "تمت المشاركة بواسطة {user}", "shared_by_you": "تمت مشاركته من قِبلك", "shared_from_partner": "صور من {partner}", + "shared_link_options": "خيارات الرابط المشترك", "shared_links": "روابط مشتركة", "shared_photos_and_videos_count": "{assetCount, plural, other {# الصور ومقاطع الفيديو المُشارَكة.}}", "shared_with_partner": "تمت المشاركة مع {partner}", @@ -1104,6 +1217,7 @@ "sharing_sidebar_description": "اعرض رابطًا للمشاركة في الشريط الجانبي", "shift_to_permanent_delete": "اضغط على ⇧ لحذف المحتوى نهائيًا", "show_album_options": "إظهار خيارات الألبوم", + "show_albums": "إظهار الألبومات", "show_all_people": "إظهار جميع الأشخاص", "show_and_hide_people": "إظهار وإخفاء الأشخاص", "show_file_location": "إظهار موقع الملف", @@ -1118,11 +1232,18 @@ "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": "رتب الألبومات حسب...", @@ -1134,6 +1255,8 @@ "sort_title": "العنوان", "source": "المصدر", "stack": "تجميع", + "stack_duplicates": "تجميع النسخ المكررة", + "stack_select_one_photo": "حدد صورة رئيسية واحدة للمجموعة", "stack_selected_photos": "كدس الصور المحددة", "stacked_assets_count": "تم تكديس {count, plural, one {# المحتوى} other {# المحتويات}}", "stacktrace": "تتّبُع التكديس", @@ -1151,27 +1274,40 @@ "submit": "إرسال", "suggestions": "اقتراحات", "sunrise_on_the_beach": "شروق الشمس على الشاطئ", + "support": "الدعم", + "support_and_feedback": "الدعم والتعليقات", + "support_third_party_description": "تم حزم تثبيت immich الخاص بك بواسطة جهة خارجية. قد تكون المشكلات التي تواجهها ناجمة عن هذه الحزمة، لذا يرجى طرح المشكلات معهم في المقام الأول باستخدام الروابط أدناه.", "swap_merge_direction": "تبديل اتجاه الدمج", "sync": "مزامنة", + "tag": "العلامة", + "tag_assets": "أصول العلامة", + "tag_created": "تم إنشاء العلامة: {tag}", + "tag_feature_description": "تصفح الصور ومقاطع الفيديو المجمعة حسب مواضيع العلامات المنطقية", + "tag_not_found_question": "لا يمكن العثور على علامة؟ قم بإنشاء علامة جديدة.", + "tag_updated": "تم تحديث العلامة: {tag}", + "tagged_assets": "تم وضع علامة {count, plural, one {# asset} other {# assets}}", + "tags": "العلامات", "template": "النموذج", "theme": "مظهر", "theme_selection": "اختيار السمة", "theme_selection_description": "قم بتعيين السمة تلقائيًا على اللون الفاتح أو الداكن بناءً على تفضيلات نظام المتصفح الخاص بك", "they_will_be_merged_together": "سيتم دمجهم معًا", + "third_party_resources": "موارد الطرف الثالث", "time_based_memories": "ذكريات استنادًا للوقت", "timezone": "المنطقة الزمنية", "to_archive": "أرشفة", "to_change_password": "تغيير كلمة المرور", "to_favorite": "تفضيل", "to_login": "تسجيل الدخول", + "to_parent": "انتقل إلى الوالد", "to_trash": "حذف", "toggle_settings": "الإعدادات", - "toggle_theme": "تبديل السمة", + "toggle_theme": "تبديل المظهر الداكن", "toggle_visibility": "تبديل الرؤية", "total_usage": "الاستخدام الإجمالي", "trash": "المهملات", "trash_all": "نقل الكل إلى سلة المهملات", - "trash_count": "{count} في المهملات", + "trash_count": "سلة المحملات {count, number}", "trash_delete_asset": "حذف/نقل المحتوى إلى سلة المهملات", "trash_no_results_message": "ستظهر هنا الصور ومقاطع الفيديو المحذوفة.", "trashed_items_will_be_permanently_deleted_after": "سيتم حذفُ العناصر المحذوفة نِهائيًا بعد {days, plural, one {# يوم} other {# أيام }}.", @@ -1185,12 +1321,15 @@ "unknown_album": "", "unknown_year": "سنة غير معروفة", "unlimited": "غير محدود", + "unlink_motion_video": "إلغاء ربط فيديو الحركة", "unlink_oauth": "إلغاء ربط OAuth", "unlinked_oauth_account": "تم إلغاء ربط حساب OAuth", "unnamed_album": "ألبوم بلا إسم", + "unnamed_album_delete_confirmation": "هل أنت متأكد أنك تريد حذف هذا الألبوم؟", "unnamed_share": "مشاركة بلا إسم", "unsaved_change": "تغيير غير محفوظ", "unselect_all": "إلغاء تحديد الكل", + "unselect_all_duplicates": "إلغاء تحديد كافة النسخ المكررة", "unstack": "فك الكومه", "unstacked_assets_count": "تم إخراج {count, plural, one {# الأصل} other {# الأصول}} من التكديس", "untracked_files": "الملفات التي لم يتم تعقبها", @@ -1200,7 +1339,7 @@ "upload": "رفع", "upload_concurrency": "الرفع المتزامن", "upload_errors": "إكتمل الرفع مع {count, plural, one {# خطأ} other {# أخطاء}}, قم بتحديث الصفحة لرؤية المحتويات الجديدة التي تم رفعها.", - "upload_progress": "متبقية {remaining} - معالجة {processed}/{total}", + "upload_progress": "متبقية {remaining, number} - معالجة {processed, number}/{total, number}", "upload_skipped_duplicates": "تم تخطي {count, plural, one {# محتوى مكرر} other {# محتويات مكررة }}", "upload_status_duplicates": "التكرارات", "upload_status_errors": "الأخطاء", @@ -1214,6 +1353,8 @@ "user_license_settings": "رخصة", "user_license_settings_description": "ادر رخصتك", "user_liked": "قام {user} بالإعجاب {type, select, photo {بهذه الصورة} video {بهذا الفيديو} asset {بهذا المحتوى} other {بها}}", + "user_purchase_settings": "الشراء", + "user_purchase_settings_description": "إدارة عملية الشراء الخاصة بك", "user_role_set": "قم بتعيين {user} كـ {role}", "user_usage_detail": "تفاصيل استخدام المستخدم", "username": "اسم المستخدم", @@ -1224,6 +1365,8 @@ "version": "الإصدار", "version_announcement_closing": "صديقك، أليكس", "version_announcement_message": "مرحباً يا صديقي، هنالك نسخة جديدة من التطبيق. خذ وقتك لزيارة ملاحظات الإصدار والتأكد من أن ملف docker-compose.yml وإعداد .env مُحدّثين لتجنب أي إعدادات خاطئة، خاصةً إذا كنت تستخدم WatchTower أو أي آلية تقوم بتحديث التطبيق تلقائياً.", + "version_history": "تاريخ الإصدار", + "version_history_item": "تم تثبيت {version} في {date}", "video": "فيديو", "video_hover_setting": "تشغيل الصورة المصغرة للفيديو عند التمرير", "video_hover_setting_description": "تشغيل الصورة المصغرة للفيديو عند تحريك الماوس فوق العنصر. حتى عند التعطيل، يمكن بدء التشغيل عن طريق التمرير فوق رمز التشغيل.", @@ -1233,6 +1376,7 @@ "view_album": "عرض الألبوم", "view_all": "عرض الكل", "view_all_users": "عرض كافة المستخدمين", + "view_in_timeline": "عرض في الجدول الزمني", "view_links": "عرض الروابط", "view_next_asset": "عرض المحتوى التالي", "view_previous_asset": "عرض المحتوى السابق", diff --git a/i18n/az.json b/i18n/az.json new file mode 100644 index 0000000000..39ce318fa8 --- /dev/null +++ b/i18n/az.json @@ -0,0 +1,86 @@ +{ + "about": "Haqqında", + "account": "Hesab", + "account_settings": "Hesab parametrləri", + "acknowledge": "Təsdiq et", + "active": "Aktiv", + "activity": "Fəaliyyət", + "add": "Əlavə et", + "add_a_description": "Təsviri əlavə et", + "add_a_location": "Məkan əlavə et", + "add_a_name": "Ad əlavə et", + "add_a_title": "Başlıq əlavə et", + "add_location": "Məkanı əlavə et", + "add_more_users": "Daha çox istifadəçi əlavə et", + "add_partner": "Partnyor əlavə et", + "add_photos": "Şəkilləri əlavə et", + "add_to": "... əlavə et", + "add_to_album": "Albom əlavə et", + "add_to_shared_album": "Paylaşılan alboma əlavə et", + "added_to_archive": "Arxivə əlavə edildi", + "added_to_favorites": "Sevimlilələrə əlavə edildi", + "added_to_favorites_count": "{count, number} şəkil sevimlilələrə əlavə edildi", + "admin": { + "authentication_settings": "Səlahiyyətləndirmə parametrləri", + "authentication_settings_description": "Şifrə, OAuth və digər səlahiyyətləndirmə parametrləri", + "authentication_settings_disable_all": "Bütün giriş etmə metodlarını söndürmək istədiyinizdən əminsinizmi? Giriş etmə funksiyası tamamilə söndürüləcəkdir.", + "authentication_settings_reenable": "Yenidən aktiv etmək üçün Server Əmri -ni istifadə edin.", + "background_task_job": "Arxa plan tapşırıqları", + "check_all": "Hamısını yoxla", + "confirm_delete_library": "{library} kitabxanasını silmək istədiyinizdən əminmisiniz?", + "confirm_email_below": "Təsdiqləmək üçün aşağıya {email} yazın", + "confirm_user_password_reset": "{user} adlı istifadəçinin şifrəsini sıfırlamaq istədiyinizdən əminmisiniz?", + "disable_login": "Giriş etməni söndür", + "duplicate_detection_job_description": "Bənzər şəkilləri tapmaq üçün maşın öyrənməsini işə salın. Bu prosses Smart Search funksiyasına əsaslanır", + "external_library_created_at": "Xarici kitabxana ({date} (tarixində yaradıldı)", + "external_library_management": "Xarici kitabxana idarəetməsi", + "face_detection": "Üz tanıma", + "force_delete_user_warning": "XƏBƏRDARLIQ: Bu əməliyyat istifadəçi və bütün məlumatları siləcəkdir. Bu prossesi və silinən faylları geri qaytarmaq olmaz.", + "forcing_refresh_library_files": "Bütün kitabxana fayllarını məcburi yeniləmə", + "image_format_description": "WebP, JPEG faylına görə daha kiçik həcmə sahibdir, lakin onu kodlaşdırmaq daha çox vaxt alır.", + "image_preview_title": "Önizləmə parametrləri", + "image_quality": "Keyfiyyət", + "image_resolution": "Çözümlülük", + "image_resolution_description": "Yüksək çözümlülükdə daha çox detallar vardır, lakin onları kodlaşdırmaq da daha çox vaxt alır, daha böyük həcmə sahib olurlar və tətbiqin işləmə sürətini yavaşladır.", + "image_settings": "Şəklin parametrləri", + "image_settings_description": "Hazırlanan şəkillərin keyfiyyətini və çözümlülüyünü idarə et", + "image_thumbnail_title": "Önizləmə parametrləri", + "job_concurrency": "{job}paralellik", + "job_created": "Tapşırıq yaradıldı", + "job_not_concurrency_safe": "Bu tapşırıq parallel fəaliyyət üçün uyğun deyil", + "job_settings": "Tapşırıq parametrləri", + "job_settings_description": "Parallel şəkildə fəaliyyət göstərən tapşırıqları idarə et", + "job_status": "Tapşırıq statusu", + "jobs_delayed": "{jobCount, plural, other {# gecikməli}}", + "jobs_failed": "{jobCount, plural, other {# uğursuz}}", + "library_created": "{library} kitabxanası yaradıldı", + "library_cron_expression": "Kron zamanlaması", + "library_cron_expression_description": "Kron zamanlama formatından istifadə edərək skan intervalının təyin edin. Daha çox məlumat üçün Crontab Guru", + "library_cron_expression_presets": "Kron zamanlamasının ilkin parametrləri", + "library_deleted": "Kitabxana silindi", + "library_import_path_description": "İdxal olunacaq qovluöu seçin. Bu qovluq, alt qovluqlar daxil olmaqla şəkil və videolar üçün skan ediləcəkdir.", + "library_scanning": "Periodik skan", + "library_scanning_description": "Periodik kitabxana skanını confiqurasiya et", + "library_scanning_enable_description": "Periodik kitabxana skanını aktivləşdir", + "library_settings": "Xarici kitabxana", + "library_settings_description": "Xarici kitabxana parametrlərini idarə et", + "library_tasks_description": "Kitabxana tapşırıqlarını yerinə yetir", + "library_watching_enable_description": "Fayl dəyişiklikləri üçün xarici kitabxanalara baxış keçirin", + "library_watching_settings": "Kitabxana nəzarəti (EKSPERİMENTAL)", + "library_watching_settings_description": "Dəyişdirilən faylları avtomatik olaraq yoxla", + "logging_enable_description": "Jurnalı aktivləşdir", + "logging_level_description": "Aktiv edildikdə hansı jurnal səviyyəsi istifadə olunur.", + "logging_settings": "", + "machine_learning_clip_model": "CLIP modeli", + "machine_learning_clip_model_description": "Buradaqeyd olunan CLIP modelinin adı. Modeli dəyişdirdikdən sonra bütün şəkillər üçün 'Ağıllı Axtarış' funksiyasını yenidən işə salmalısınız.", + "machine_learning_duplicate_detection": "Dublikat Aşkarlama", + "machine_learning_duplicate_detection_enabled": "Dublikat aşkarlamanı aktiv etmək", + "machine_learning_duplicate_detection_enabled_description": "Əgər deaktiv edilibsə, birə-bir eyni fayllar yenədə silinəcək.", + "machine_learning_duplicate_detection_setting_description": "Bir-birinin dublikatı olan faylları tapmaq üçün CLIP-dən istifadə edin", + "machine_learning_enabled": "Maşın öyrənməsini aktiv edin", + "machine_learning_enabled_description": "Əgər deaktiv edilərsə, aşağıdakı parametrlərdən asılı olmayaq, bütün Maşın Öyrənmə funksiyaları deaktiv ediləcək.", + "machine_learning_facial_recognition": "Üz Tanıma", + "machine_learning_facial_recognition_description": "Şəkillərdəki üzləri aşkarla, tanı və qruplaşdır", + "machine_learning_facial_recognition_model": "Üz tanıma modeli" + } +} diff --git a/web/src/lib/i18n/be.json b/i18n/be.json similarity index 100% rename from web/src/lib/i18n/be.json rename to i18n/be.json diff --git a/web/src/lib/i18n/bg.json b/i18n/bg.json similarity index 67% rename from web/src/lib/i18n/bg.json rename to i18n/bg.json index 1581d7b952..fde9434e30 100644 --- a/web/src/lib/i18n/bg.json +++ b/i18n/bg.json @@ -12,22 +12,23 @@ "add_a_description": "Добави описание", "add_a_location": "Добави местоположение", "add_a_name": "Добави име", - "add_a_title": "Добави заглавие", + "add_a_title": "Добавете заглавие", "add_exclusion_pattern": "Добави модел за изключване", "add_import_path": "Добави път за импортиране", - "add_location": "Добави местоположение", - "add_more_users": "Добави още потребители", - "add_partner": "Добави партньор", + "add_location": "Добавете местоположение", + "add_more_users": "Добавете още потребители", + "add_partner": "Добавете партньор", "add_path": "Добави път", - "add_photos": "Добави снимки", + "add_photos": "Добавете снимки", "add_to": "Добави към...", "add_to_album": "Добави към албум", "add_to_shared_album": "Добави към споделен албум", - "added_to_archive": "Добавено в архива", - "added_to_favorites": "Добавено към любими", - "added_to_favorites_count": "Добавени {count} към любими", + "added_to_archive": "Добавено към архива", + "added_to_favorites": "Добавени към любимите ви", + "added_to_favorites_count": "Добавени {count, number} към любими", "admin": { "add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".", + "asset_offline_description": "Този външен библиотечен елемент не може да бъде открит на диска и е преместен в кошчето за боклук. Ако файлът е преместен в библиотеката, проверете вашата история за нов съответстващ елемент. За да възстановите елемента, моля проверете дали файловият път отдолу може да бъде достъпен от Immich и сканирайте библиотеката.", "authentication_settings": "Настройки за удостоверяване", "authentication_settings_description": "Управление на парола, OAuth и други настройки за удостоверяване", "authentication_settings_disable_all": "Сигурни ли сте, че искате да деактивирате всички методи за вписване? Вписването ще бъде напълно деактивирано.", @@ -41,6 +42,7 @@ "confirm_email_below": "За потвърждение, моля въведете \"{email}\" отдолу", "confirm_reprocess_all_faces": "Сигурни ли сте, че искате да се обработят лицата отново? Това ще изчисти всички именувани хора.", "confirm_user_password_reset": "Сигурни ли сте, че искате да нулирате паролата на {user}?", + "create_job": "Създайте задача", "disable_login": "Изключете вписването", "duplicate_detection_job_description": "Стартиране машинно обучение на ресурси, за откриване на подобни изображения. Разчита на Интелигентно Търсене", "exclusion_pattern_description": "Модели за изключване позволяват да игнорирате файлове и папки, когато сканирате вашата библиотека. Това е потребно, ако имате папки, които съдържат файлове, които не искате да импортирате. Примерно - RAW файлове.", @@ -68,6 +70,7 @@ "image_thumbnail_resolution": "Резолюция на миниатюрните изображения", "image_thumbnail_resolution_description": "Използва се при разглеждане на групи от снимки (основна времева линия, изглед на албум и др.). По-високите резолюции могат да запазят повече детайли, но отнемат повече време за кодиране, имат по-големи размери на файловете и могат да намалят отзивчивостта на приложението.", "job_concurrency": "Паралелност на {job}", + "job_created": "Задачата е създадена", "job_not_concurrency_safe": "Тази задача не е безопасна за паралелно изпълнение.", "job_settings": "Настройки за задачите", "job_settings_description": "Управление на паралелността на задачите", @@ -127,16 +130,21 @@ "map_enable_description": "Активиране на картата", "map_gps_settings": "Настройки на картата и GPS", "map_gps_settings_description": "Управление на настройките на картата и GPS (обратно геокодиране)", + "map_implications": "Функцията за карта разчита на външна услуга (tiles.immich.cloud)", "map_light_style": "Светъл стил", "map_manage_reverse_geocoding_settings": "Управление на настройките за обратно геокодиране", "map_reverse_geocoding": "Обратно геокодиране", "map_reverse_geocoding_enable_description": "Включване на обратно геокодиране", "map_reverse_geocoding_settings": "Настройки на опбратно геокодиране", - "map_settings": "Настройки на картата", + "map_settings": "Карта", "map_settings_description": "Управление на настройките на картата", "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", "metadata_extraction_job": "Извличане на метаданни", - "metadata_extraction_job_description": "Извличане на метаданни от всеки ресурс, като GPS и резолюция", + "metadata_extraction_job_description": "Извличане на метаданни от всеки от ресурсите, като GPS локация, лица и резолюция на файловете", + "metadata_faces_import_setting": "Включи импорт на лице", + "metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове", + "metadata_settings": "Опции за метаданни", + "metadata_settings_description": "Управление на настройките за метаданни", "migration_job": "Миграция", "migration_job_description": "Мигриране на миниатюрите за ресурси и лица към най-новата структура на папките", "no_paths_added": "Няма добавени пътища", @@ -145,7 +153,7 @@ "note_cannot_be_changed_later": "ВНИМАНИЕ: Това не може да бъде променено по-късно!", "note_unlimited_quota": "Бележка: Въведете 0 за да нямате лимит на квотата", "notification_email_from_address": "От адрес", - "notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server \"", + "notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server \"", "notification_email_host_description": "Хост на сървъра за електронна поща (например: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игорнорирайте сертификационни грешки", "notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)", @@ -171,7 +179,7 @@ "oauth_issuer_url": "URL на издателя", "oauth_mobile_redirect_uri": "URI за мобилно пренасочване", "oauth_mobile_redirect_uri_override": "URI пренасочване за мобилни устройства", - "oauth_mobile_redirect_uri_override_description": "Разреши когато 'app.immich:/' е невалиден пренасочвар адрес/URI.", + "oauth_mobile_redirect_uri_override_description": "Разреши когато доставчика за OAuth удостоверяване не позволява за мобилни URI идентификатори, като '{callback}'", "oauth_profile_signing_algorithm": "Алгоритъм за създаване на профили", "oauth_profile_signing_algorithm_description": "Алгоритъм излпозлван за вписване на потребителски профил.", "oauth_scope": "Област/обхват на приложение", @@ -195,15 +203,17 @@ "refreshing_all_libraries": "Опресняване на всички библиотеки", "registration": "Администраторска регистрация", "registration_description": "Тъй като сте първият потребител в системата, ще бъдете назначен като администратор и ще отговаряте за административните задачи, а допълнителните потребители ще бъдат създадени от вас.", - "removing_offline_files": "Премахване на офлайн файлове", + "removing_deleted_files": "Премахване на офлайн файлове", "repair_all": "Поправяне на всичко", "repair_matched_items": "{count, plural, one {Съвпадащ елемент (#)} other {Съвпадащи елементи (#)}}", "repaired_items": "{count, plural, one {Поправен елемент (#)} other {Поправени елементи (#)}}", "require_password_change_on_login": "Изискване за промяна паролата при първо влизане", "reset_settings_to_default": "Възстановяване на настройките по подразбиране", "reset_settings_to_recent_saved": "Възстановяване на настройките до последните запазени настройки", + "scanning_library": "Сканиране на библиотеката", "scanning_library_for_changed_files": "Сканиране на библиотеката за променени файлове", "scanning_library_for_new_files": "Сканиране на библиотеката за нови файлове", + "search_jobs": "Търсене на задачи...", "send_welcome_email": "Изпращане на имейл за добре дошли", "server_external_domain_settings": "Външен домейн", "server_external_domain_settings_description": "Домейн за публични споделени връзки, включително http(s)://", @@ -225,127 +235,149 @@ "storage_template_migration_info": "Промените в шаблоните ще се прилагат само за нови ресурси. За да приложите шаблона със задна дата към предварително качени активи, изпълнете {job}.", "storage_template_migration_job": "Задача за миграция на шаблона за съхранение", "storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона Storage Template и неговите последствия ", - "storage_template_onboarding_description": "", + "storage_template_onboarding_description": "Когато е активирана, тази функция ще организира автоматично файлове въз основа на дефиниран от потребителя шаблон. Поради проблеми със стабилността, функцията е изключена по подразбиране. За повече информация, моля, вижте документацията.", "storage_template_path_length": "Ограничение на дължината на пътя: {length, number}/{limit, number}", "storage_template_settings": "Шаблон за съхранение", "storage_template_settings_description": "Управление на структурата на папките и името на файла за качване", - "storage_template_user_label": "", + "storage_template_user_label": "{label} е етикетът за съхранение на потребителя", "system_settings": "Системни настройки", "theme_custom_css_settings": "Персонализиран CSS", - "theme_custom_css_settings_description": "", + "theme_custom_css_settings_description": "Каскадните стилови таблици позволяват персонализиране на дизайна на Immich.", "theme_settings": "Настройки на темата", "theme_settings_description": "Управление на персонализирането на уеб интерфейса на Immich", "these_files_matched_by_checksum": "Тези файлове се сравняват по контролните им суми (checksums)", "thumbnail_generation_job": "Генериране на миниатюри", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", + "thumbnail_generation_job_description": "Генерирайте големи, малки и замъглени миниатюри за всеки актив, както и миниатюри за всеки човек", + "transcoding_acceleration_api": "API за ускоряване", + "transcoding_acceleration_api_description": "API интерфейсът, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „възможно най-доброто“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може и да не работи в зависимост от вашия хардуер.", "transcoding_acceleration_nvenc": "NVENC (необходим NVIDIA GPU)", "transcoding_acceleration_qsv": "Quick Sync (необходим 7th поколение Intel CPU или по-ново)", "transcoding_acceleration_rkmpp": "RKMPP (само на Rockchip SOCs)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Допустими аудио кодеци", - "transcoding_accepted_audio_codecs_description": "", + "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_bitrate_description": "Видеоклипове с по-висок от максималния битрейт или не в приет формат", - "transcoding_codecs_learn_more": "", + "transcoding_codecs_learn_more": "За да научите повече за използваната терминология, вижте документацията на FFmpeg за кодек H.264, кодек HEVC и VP9 кодек.", "transcoding_constant_quality_mode": "Режим на постоянно качество", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", + "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_hardware_acceleration": "Хардуерно ускорение", "transcoding_hardware_acceleration_description": "Експериментално; много по-бързо, но с по-ниско качество при същия битрейт", "transcoding_hardware_decoding": "Хардуерно декодиране", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_hardware_decoding_setting_description": "Прилага се само за NVENC, QSV и RKMPP. Активира ускорение от край до край, вместо само да ускорява кодирането. Може да не работи с всички видеоклипове.", "transcoding_hevc_codec": "HEVC кодек", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames": "Максимални B-фрейма", + "transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.", "transcoding_max_bitrate": "Максимален битрейт", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", + "transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600k за VP9 или HEVC или 4500k за H.264. Деактивирано, ако е зададено на 0.", + "transcoding_max_keyframe_interval": "Максимален интервал между ключовите кадри", + "transcoding_max_keyframe_interval_description": "Задава максималното разстояние между ключовите кадри. По-ниските стойности влошават ефективността на компресията, но подобряват времето за търсене и могат да подобрят качеството в сцени с бързо движение. 0 задава тази стойност автоматично.", "transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат", "transcoding_preferred_hardware_device": "Предпочитано хардуерно устройство", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", + "transcoding_preferred_hardware_device_description": "Прилага се само за VAAPI и QSV. Задава dri възела, използван за хардуерно транскодиране.", + "transcoding_preset_preset": "Предварително зададени(-preset)", + "transcoding_preset_preset_description": "Скорост на компресия. По-бавните предварително зададени настройки създават по-малки файлове и повишават качеството при насочване към определен битрейт. VP9 игнорира скорости над „по-бързо“.", + "transcoding_reference_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_target_resolution_description": "По-високите разделителни способности могат да представят повече детайли, но отнемат повече време за разкодиране, имат по-големи размери на файловете и могат да намалят отзивчивостта на приложението.", + "transcoding_temporal_aq": "Темпорален AQ", "transcoding_temporal_aq_description": "Само за NVENC. Повишава качеството на сцени с висока детайлност и ниско ниво на движение. Може да не е съвместимо с по-стари устройства.", "transcoding_threads": "Нишки", - "transcoding_threads_description": "", + "transcoding_threads_description": "По-високите стойности водят до по-бързо разкодиране, но оставят по-малко място за сървъра да обработва други задачи, докато е активен. Тази стойност не трябва да надвишава броя на процесорните ядра. Увеличава максимално използването, ако е зададено на 0.", "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "Опитва се да запази външния вид на HDR видеоклипове, когато се преобразува в SDR. Всеки алгоритъм прави различни компромиси за цвят, детайлност и яркост. Hable запазва детайлите, Mobius запазва цвета, а Reinhard запазва яркостта.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_tone_mapping_npl_description": "Цветовете ще бъдат коригирани, за да изглеждат нормално за дисплей с тази яркост. Противоинтуитивно, по-ниските стойности увеличават яркостта на видеото и обратно, тъй като компенсират яркостта на дисплея. 0 задава тази стойност автоматично.", + "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": "", - "trash_enabled_description": "", + "transcoding_video_codec_description": "VP9 има висока ефективност и уеб съвместимост, но отнема повече време за транскодиране. HEVC работи по подобен начин, но има по-ниска уеб съвместимост. H.264 е широко съвместим и бърз за разкодиране, но създава много по-големи файлове. AV1 е най-ефективният кодек, но му липсва поддръжка на по-стари устройства.", + "trash_enabled_description": "Активирайте функциите за кошче", "trash_number_of_days": "Брой дни", "trash_number_of_days_description": "Брой дни, в които файловете да се съхраняват на боклука, преди да бъдат окончателно премахнати", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", + "trash_settings": "Настройки на кошчето", + "trash_settings_description": "Управление на настройките на кошчето", + "untracked_files": "Непроследени файлове", "untracked_files_description": "Тези файлове не се проследяват от приложението. Те могат да бъдат резултат от неуспешни премествания, прекъснати качвания или оставени поради грешка", "user_delete_delay": "{user} aкаунтът и файловете на потребителя ще бъдат планирани за постоянно изтриване след {delay, plural, one {# ден} other {# дни}}.", "user_delete_delay_settings": "Забавяне на изтриване", - "user_delete_delay_settings_description": "", + "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} aкаунтът ще бъде възстановен.", + "user_restore_scheduled_removal": "Възстановяване на потребител – с насрочено премахване на {date, date, long}", "user_settings": "Настройки на потребителя", "user_settings_description": "Управление на потребителските настройки", "user_successfully_removed": "Потребителят {email} е успешно премахнат.", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", + "version_check_enabled_description": "Активирай проверка на версията", + "version_check_implications": "Функцията за проверка на версията разчита на периодична комуникация с github.com", + "version_check_settings": "Проверка на версията", + "version_check_settings_description": "Активирайте/деактивирайте известието за нова версия", "video_conversion_job": "Транскодиране на видеоклиповете", - "video_conversion_job_description": "" + "video_conversion_job_description": "Транскодирай видеоклипове за по-широка съвместимост с браузъри и устройства" }, "admin_email": "Администраторски имейл адрес", "admin_password": "Администраторска парола", "administration": "Администрация", "advanced": "Разширено", "album_added": "Албумът е добавен", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", + "album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум", + "album_cover_updated": "Обложката на албума е актуализирана", + "album_delete_confirmation": "Сигурни ли сте, че искате да изтриете албума {album}?", + "album_delete_confirmation_description": "Ако този албум е споделен, други потребители вече няма да имат достъп до него.", + "album_info_updated": "Информацията за албума е актуализирана", + "album_leave": "Да напусна ли албума?", + "album_leave_confirmation": "Сигурни ли сте, че искате да напуснете {album}?", "album_name": "Име на албума", "album_options": "Настройки на албума", + "album_remove_user": "Премахване на потребител?", + "album_remove_user_confirmation": "Сигурни ли сте, че искате да премахнете {user}?", + "album_share_no_users": "Изглежда, че сте споделили този албум с всички потребители или нямате друг потребител, с когото да го споделите.", "album_updated": "Албумът е актуализиран", - "album_updated_setting_description": "", + "album_updated_setting_description": "Получавайте известие по имейл, когато споделен албум има нови файлове", + "album_user_left": "Напусна {album}", + "album_user_removed": "Премахнат {user}", + "album_with_link_access": "Нека всеки с линк вижда снимки и хора в този албум.", "albums": "Албуми", "albums_count": "", "all": "Всички", + "all_albums": "Всички албуми", "all_people": "Всички хора", - "allow_dark_mode": "", + "all_videos": "Всички видеоклипове", + "allow_dark_mode": "Разреши тъмен режим", "allow_edits": "Позволяване на редакции", - "api_key": "", - "api_keys": "", + "allow_public_user_to_download": "Позволете на публичен потребител да може да изтегля", + "allow_public_user_to_upload": "Позволете на публичния потребител да може да качва", + "api_key": "API ключ", + "api_key_description": "Тази стойност ще бъде показана само веднъж. Моля, не забравяйте да го копирате, преди да затворите прозореца.", + "api_key_empty": "Името на вашия API ключ не трябва да е празно", + "api_keys": "API ключове", "app_settings": "Настройки ма приложението", "appears_in": "", "archive": "Архив", - "archive_or_unarchive_photo": "", + "archive_or_unarchive_photo": "Архивиране или деархивиране на снимка", "archive_size": "Размер на архива", - "archive_size_description": "", + "archive_size_description": "Конфигурирайте размера на архива за изтегляния (в GiB)", "archived": "", + "are_these_the_same_person": "Това едно и също лице ли е?", "asset_offline": "Ресурсът е офлайн", "asset_skipped": "Пропуснато", "asset_uploaded": "Качено", @@ -354,17 +386,22 @@ "assets_moved_to_trash": "", "authorized_devices": "Удостоверени устройства", "back": "Назад", + "back_close_deselect": "Назад, затваряне или премахване на избора", "backward": "Назад", + "birthdate_saved": "Датата на раждане е запазена успешно", + "birthdate_set_description": "Датата на раждане се използва за изчисляване на възрастта на този човек към момента на снимката.", "blurred_background": "Замъглен заден фон", "bulk_delete_duplicates_confirmation": "", "bulk_keep_duplicates_confirmation": "", "bulk_trash_duplicates_confirmation": "", + "buy": "Купете Immich", "camera": "Камера", "camera_brand": "Марка на камерата", "camera_model": "Модел на камерата", "cancel": "Откажи", "cancel_search": "Отмени търсенето", - "cannot_merge_people": "", + "cannot_merge_people": "Не може да обединява хора", + "cannot_undo_this_action": "Не можете да отмените това действие!", "cannot_update_the_description": "Описанието не може да бъде актуализирано", "cant_apply_changes": "", "cant_get_faces": "", @@ -376,6 +413,7 @@ "change_name": "Промени името", "change_name_successfully": "Името е успешно променено", "change_password": "Промени паролата", + "change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.", "change_your_password": "Променете паролата си", "changed_visibility_successfully": "Видимостта е променена успешно", "check_all": "Провери всичко", @@ -384,6 +422,7 @@ "city": "Град", "clear": "Изчисти", "clear_all": "Изчисти всичко", + "clear_all_recent_searches": "Изчистете всички скорошни търсения", "clear_message": "Изчисти съобщението", "clear_value": "Изчисти стойността", "close": "Затвори", @@ -391,7 +430,7 @@ "collapse_all": "Свиване на всичко", "color_theme": "Цветова тема", "comment_deleted": "Коментарът е изтрит", - "comment_options": "", + "comment_options": "Опции за коментар", "comments_and_likes": "Коментари и харесвания", "comments_are_disabled": "Коментарите са деактивирани", "confirm": "Потвърди", @@ -404,15 +443,15 @@ "copied_image_to_clipboard": "Изображението е копирано в клипборда.", "copied_to_clipboard": "Копирано в клипборда!", "copy_error": "Грешка при копирането", - "copy_file_path": "", + "copy_file_path": "Копирай пътя на файла", "copy_image": "Копиране на изображението", "copy_link": "Копиране на линк", - "copy_link_to_clipboard": "", + "copy_link_to_clipboard": "Копиране на връзката в клипборда", "copy_password": "Копиране на парола", - "copy_to_clipboard": "", + "copy_to_clipboard": "Копиране в клипборда", "country": "Държава", "cover": "", - "covers": "", + "covers": "Обложка", "create": "Създай", "create_album": "Създай албум", "create_library": "Създай библиотека", @@ -433,7 +472,7 @@ "date_of_birth_saved": "Дата на раждане е записана успешно", "date_range": "Период от време", "day": "Ден", - "deduplicate_all": "", + "deduplicate_all": "Дедупликиране на всички", "default_locale": "", "default_locale_description": "Форматиране на дати и числа в зависимост от местоположението на браузъра", "delete": "Изтрий", @@ -457,7 +496,7 @@ "display_options": "Опции за показване", "display_order": "Ред на показване", "display_original_photos": "Показване на оригинални снимки", - "display_original_photos_setting_description": "", + "display_original_photos_setting_description": "Показване на оригиналната снимка вместо миниатюри, когато оригиналният актив е съвместим с мрежата. Това може да доведе до по-бавни скорости на показване на снимки.", "do_not_show_again": "Не показвайте това съобщение отново", "done": "Готово", "download": "Изтегли", @@ -474,11 +513,11 @@ "edit_avatar": "Редактиране на аватар", "edit_date": "Редактиране на дата", "edit_date_and_time": "Редактиране на дата и час", - "edit_exclusion_pattern": "", + "edit_exclusion_pattern": "Редактиране на шаблон за изключване", "edit_faces": "Редактиране на лица", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", + "edit_import_path": "Редактиране на пътя за импортиране", + "edit_import_paths": "Редактиране на пътища за импортиране", + "edit_key": "Редактиране на ключ", "edit_link": "Редактиране на линк", "edit_location": "Редактиране на местоположението", "edit_name": "Редактиране на име", @@ -489,6 +528,7 @@ "editor": "", "email": "Имейл", "empty_trash": "Изпразване на кош", + "empty_trash_confirmation": "Сигурни ли сте, че искате да изпразните кошчето? Това ще премахне всичко в кошчето за постоянно от Immich.\nНе можете да отмените това действие!", "enable": "Включване", "enabled": "Включено", "end_date": "Крайна дата", @@ -498,16 +538,22 @@ "errors": { "cannot_navigate_next_asset": "Не можете да преминете към следващия файл", "cannot_navigate_previous_asset": "Не можете да преминете към предишния актив", + "cant_apply_changes": "Не могат да се приложат промение", "cant_change_asset_favorite": "Не може да промени любими за файл", + "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_removing_assets_from_album": "Грешка при премахването на файловете от албума, проверете конзолата за повече информация", "error_selecting_all_assets": "Грешка при избора на всички файлове", - "exclusion_pattern_already_exists": "", + "exclusion_pattern_already_exists": "Този модел за изключване вече съществува.", + "failed_job_command": "Командата {command} е неуспешна за задача: {job}", "failed_to_create_album": "Неуспешно създаване на албум", "failed_to_create_shared_link": "Неуспешно създаване на споделена връзка", "failed_to_edit_shared_link": "Неуспешно редактиране на споделена връзка", @@ -525,28 +571,36 @@ "unable_to_add_exclusion_pattern": "", "unable_to_add_import_path": "", "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", + "unable_to_change_album_user_role": "Не може да се промени ролята на потребителя на албума", + "unable_to_change_date": "Не може да се промени датата", + "unable_to_change_favorite": "Не може да промени фаворит за актив", + "unable_to_change_location": "Не може да се промени местоположението", + "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": "Не може да се свърже със сървъра", + "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_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", + "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_hide_person": "Не може да скрие човек", "unable_to_link_oauth_account": "", "unable_to_load_album": "", "unable_to_load_asset_activity": "", @@ -556,8 +610,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -846,10 +900,10 @@ "refreshed": "Опреснено", "refreshes_every_file": "", "remove": "Премахни", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "Преименувай", "repair": "Поправи", @@ -889,157 +943,200 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "search_people": "Търсете на хора", + "search_places": "Търсене на места", "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", + "search_tags": "Търсене на етикети...", + "search_timezone": "Търсене на часова зона...", + "search_type": "Тип на търсене", + "search_your_photos": "Търсете вашите снимки", "searching_locales": "", "second": "Секунда", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", + "see_all_people": "Вижте всички хора", + "select_album_cover": "Изберете обложка на албум", + "select_all": "Изберете всички", + "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": "", + "select_library_owner": "Изберете собственик на библиотека", + "select_new_face": "Изберете ново лице", + "select_photos": "Изберете снимки", + "select_trash_all": "Изберете всичко за кошчето", "selected": "Избрано", - "send_message": "", - "send_welcome_email": "", + "send_message": "Изпратете съобщение", + "send_welcome_email": "Изпратете имейл за добре дошли", "server": "Сървър", - "server_stats": "", + "server_offline": "Сървър офлайн", + "server_online": "Сървър онлайн", + "server_stats": "Статус на сървъра", + "server_version": "Версия на сървъра", "set": "Задай", - "set_as_album_cover": "", - "set_as_profile_picture": "", - "set_date_of_birth": "", - "set_profile_picture": "", - "set_slideshow_to_fullscreen": "", + "set_as_album_cover": "Задаване като обложка на албум", + "set_as_profile_picture": "Задаване като профилна снимка", + "set_date_of_birth": "Задайте дата на раждане", + "set_profile_picture": "Задайте профилна снимка", + "set_slideshow_to_fullscreen": "Задайте Слайдшоу на цял екран", "settings": "Настройки", - "settings_saved": "", + "settings_saved": "Настройките са запазени", "share": "Споделяне", "shared": "Споделено", - "shared_by": "", - "shared_by_you": "", - "shared_from_partner": "", - "shared_links": "", + "shared_by": "Споделено от", + "shared_by_user": "Споделено от {user}", + "shared_by_you": "Споделено от теб", + "shared_from_partner": "Снимки от {partner}", + "shared_link_options": "Опции за споделена връзка", + "shared_links": "Споделени връзки", "shared_photos_and_videos_count": "", - "shared_with_partner": "", + "shared_with_partner": "Споделено с {partner}", "sharing": "Споделени", - "sharing_sidebar_description": "", - "show_album_options": "", - "show_and_hide_people": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", + "sharing_enter_password": "Моля, въведете паролата, за да видите тази страница.", + "sharing_sidebar_description": "Покажи връзка към Споделяне в страничната лента", + "show_album_options": "Показване опции за албум", + "show_albums": "Покажи албуми", + "show_all_people": "Покажи всички хора", + "show_and_hide_people": "Показване и скриване на хора", + "show_file_location": "Покажи местоположението на файла", + "show_gallery": "Покажи галерия", + "show_hidden_people": "Показване на скритите хора", "show_in_timeline": "Показване във времевата линия", "show_in_timeline_setting_description": "Показване на снимки и видеа от този потребител във времевата линия", - "show_keyboard_shortcuts": "", - "show_metadata": "", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", + "show_keyboard_shortcuts": "Покажи клавишни комбинации", + "show_metadata": "Покажи метаданни", + "show_or_hide_info": "Покажи или скрий информацията", + "show_password": "Покажи паролата", + "show_person_options": "Показване на опции за лица", + "show_progress_bar": "Показване на прогрес бара", + "show_search_options": "Показване на опциите за търсене", + "show_supporter_badge": "Значка поддръжник", + "show_supporter_badge_description": "Покажи значка поддръжник", "shuffle": "Разбъркване", - "sign_out": "", - "sign_up": "", + "sidebar": "Странична лента", + "sidebar_display_description": "Показване на връзка към изгледа в страничната лента", + "sign_out": "Отписване", + "sign_up": "Запиши се", "size": "Размер", - "skip_to_content": "", + "skip_to_content": "Премини към съдържанието", + "skip_to_folders": "Премини към папките", + "skip_to_tags": "Премини към етикетите", "slideshow": "Слайдшоу", - "slideshow_settings": "", - "sort_albums_by": "", + "slideshow_settings": "Настройки за слайдшоу", + "sort_albums_by": "Сортиране на албуми по...", + "sort_created": "Дата на създаване", + "sort_items": "Брой елементи", + "sort_modified": "Дата на промяна", + "sort_oldest": "Най-старата снимка", + "sort_recent": "Най-новата снимка", "sort_title": "Заглавие", "source": "Източник", "stack": "", - "stack_selected_photos": "", + "stack_duplicates": "Подреждане на дубликати", + "stack_selected_photos": "Подреждане на избрани снимки", "stacktrace": "", "start": "Старт", - "start_date": "", + "start_date": "Начална дата", "state": "", "status": "Статус", "stop_motion_photo": "", - "stop_photo_sharing": "", - "stop_photo_sharing_description": "", - "stop_sharing_photos_with_user": "", - "storage": "Пространство", - "storage_label": "", - "storage_usage": "", + "stop_photo_sharing": "Да спра ли споделянето на вашите снимки?", + "stop_photo_sharing_description": "{partner} вече няма достъп до вашите снимки.", + "stop_sharing_photos_with_user": "Прекратете споделянето на снимки с този потребител", + "storage": "Пространство на хранилището", + "storage_label": "Наименование на хранилището", + "storage_usage": "Използвани {used} от {available}", "submit": "Изпращане", "suggestions": "Предложения", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "Изгрев на плажа", + "swap_merge_direction": "Размяна посоката на сливане", "sync": "Синхронизиране", + "tag": "Таг", + "tag_created": "Създаден етикет: {tag}", + "tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове", + "tag_not_found_question": "Не можете да намерите етикет? Създайте такъв тук", + "tag_updated": "Актуализиран етикет: {tag}", + "tags": "Етикет", "template": "Шаблон", "theme": "Тема", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", + "theme_selection": "Избор на тема", + "theme_selection_description": "Автоматично задаване на светла или тъмна тема въз основа на системните предпочитания на вашия браузър", + "they_will_be_merged_together": "Те ще бъдат обединени", + "time_based_memories": "Спомени, базирани на времето", "timezone": "Часова зона", "to_archive": "Архивирай", + "to_change_password": "Промяна на паролата", "to_favorite": "Любим", "to_login": "Вписване", "to_trash": "Кошче", - "toggle_settings": "", - "toggle_theme": "", + "toggle_settings": "Превключване на настройките", + "toggle_theme": "Превключване на тема", "toggle_visibility": "", - "total_usage": "", + "total_usage": "Общо използвано", "trash": "кошче", - "trash_all": "", - "trash_count": "", - "trash_no_results_message": "", - "trashed_items_will_be_permanently_deleted_after": "", + "trash_all": "Изхвърли всички", + "trash_count": "Кошче {count, number}", + "trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.", + "trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# day} other {# days}}.", "type": "Тип", "unarchive": "Разархивирай", "unarchived": "", "unfavorite": "Премахване от любимите", "unhide_person": "", "unknown": "Неизвестно", - "unknown_year": "", + "unknown_year": "Неизвестна година", "unlimited": "Неограничено", "unlink_oauth": "", "unlinked_oauth_account": "", - "unnamed_album": "", - "unnamed_share": "", - "unselect_all": "", + "unnamed_album": "Албум без име", + "unnamed_album_delete_confirmation": "Сигурни ли сте, че искате да изтриете този албум?", + "unnamed_share": "Споделяне без име", + "unsaved_change": "Незапазена промяна", + "unselect_all": "Деселектирайте всички", + "unselect_all_duplicates": "От маркирай всички дубликати", "unstack": "", - "untracked_files": "", - "untracked_files_decription": "", - "up_next": "", - "updated_password": "", + "untracked_files": "Непознати файлове", + "untracked_files_decription": "Тези файлове са не разпознати от приложението. Те могат да бъдат резултат от неуспешни прехвърля ния, прекъснати качвания или незавършени поради грешка", + "up_next": "Следващ", + "updated_password": "Паролата е актуализирана", "upload": "Качване", "upload_concurrency": "", + "upload_progress": "Остават {remaining, number} - Обработени {processed, number}/{total, number}", "upload_status_duplicates": "Дубликати", "upload_status_errors": "Грешки", "upload_status_uploaded": "Качено", - "url": "", + "upload_success": "Качването е успешно, опреснете страницата, за да видите новите файлове.", + "url": "URL", "usage": "Потребление", "user": "Потребител", - "user_id": "", - "user_usage_detail": "", + "user_id": "Потребител ИД", + "user_purchase_settings": "Покупка", + "user_purchase_settings_description": "Управлявай покупката си", + "user_role_set": "Задай {user} като {role}", + "user_usage_detail": "Подробности за използването на потребителя", "username": "Потребителско име", "users": "Потребители", "utilities": "Инструменти", "validate": "Валидиране", "variables": "Променливи", "version": "Версия", - "version_announcement_message": "", + "version_announcement_closing": "Твой приятел, Алекс", + "version_announcement_message": "Здравей, има нова версия на приложението. Моля, отдели малко време, за да разгледаш новости те за версията и да се увериш, че docker-compose.yml и .env е актуална, за да се предотвратят неправилни конфигурации, особено ако използвате WatchTower или друг механизъм, който управлява автоматичното актуализиране на вашето приложение.", "video": "Видеоклип", "video_hover_setting": "Възпроизвеждане на видеоклип при посочване с мишката", - "video_hover_setting_description": "", + "video_hover_setting_description": "Възпроизвеждане на видеоклипа, когато мишката се движи над елемента. Дори когато е деактивирано, възпроизвеждането може да бъде стартирано чрез задържане на курсора на мишката върху иконата за възпроизвеждане.", "videos": "Видеоклипове", - "videos_count": "", + "videos_count": "{count, plural, one {# Видео} other {# Видеа}}", "view": "Преглед", "view_album": "Разгледай албума", "view_all": "Преглед на всички", "view_all_users": "Преглед на всички потребители", + "view_in_timeline": "Покажи във времева линия", "view_links": "Преглед на връзките", "view_next_asset": "Преглед на следващия файл", "view_previous_asset": "Преглед на предишния файл", + "view_stack": "Покажи в стек", "viewer": "", + "visibility_changed": "Видимостта е променена за {count, plural, one {# person} other {# people}}", "waiting": "в изчакване", "warning": "Внимание", "week": "Седмица", diff --git a/web/src/lib/i18n/bi.json b/i18n/bi.json similarity index 99% rename from web/src/lib/i18n/bi.json rename to i18n/bi.json index 7d70cb8434..aa5e3401c0 100644 --- a/web/src/lib/i18n/bi.json +++ b/i18n/bi.json @@ -172,7 +172,7 @@ "paths_validated_successfully": "", "quota_size_gib": "", "refreshing_all_libraries": "", - "removing_offline_files": "", + "removing_deleted_files": "", "repair_all": "", "repair_matched_items": "", "repaired_items": "", @@ -485,8 +485,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -718,10 +718,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "", "repair": "", diff --git a/web/src/lib/i18n/ca.json b/i18n/ca.json similarity index 86% rename from web/src/lib/i18n/ca.json rename to i18n/ca.json index c9f06223e5..86d85becf7 100644 --- a/web/src/lib/i18n/ca.json +++ b/i18n/ca.json @@ -7,10 +7,10 @@ "actions": "Accions", "active": "Actiu", "activity": "Activitat", - "activity_changed": "L'activitat està {enabled, select, true {enabled} other {disabled}}", - "add": "Agregar", - "add_a_description": "Afegir una descripció", - "add_a_location": "Afegir una ubicació", + "activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}", + "add": "Afegir", + "add_a_description": "Afegiu una descripció", + "add_a_location": "Afegiu una ubicació", "add_a_name": "Afegir un nom", "add_a_title": "Afegir un títol", "add_exclusion_pattern": "Afegir un patró d'exclusió", @@ -28,6 +28,7 @@ "added_to_favorites_count": "{count, number} afegits als preferits", "admin": { "add_exclusion_pattern_description": "Afegeix patrons d'eclusió. És permès de l'ús de *, **, i ? (globbing). Per a ignorar els fitxers de qualsevol directori anomenat \"Raw\" introduïu \"**/Raw/**\". Per a ignorar els fitxers acabats en \".tif\" introduïu \"**/*.tif\". Per a ignorar un camí absolut, utilitzeu \"/camí/a/ignorar/**\".", + "asset_offline_description": "Aquest recurs de la biblioteca externa ja no es troba al disc i s'ha mogut a la paperera. Si el fitxer s'ha mogut dins de la biblioteca, comproveu la vostra línia de temps per trobar el nou recurs corresponent. Per restaurar aquest recurs, assegureu-vos que Immich pugui accedir a la ruta del fitxer següent i escanegeu la biblioteca.", "authentication_settings": "Configuració de l'autenticació", "authentication_settings_description": "Gestiona la contrasenya, OAuth i altres configuracions de l'autenticació", "authentication_settings_disable_all": "Estàs segur que vols desactivar tots els mètodes d'inici de sessió? L'inici de sessió quedarà completament desactivat.", @@ -41,6 +42,7 @@ "confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota", "confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.", "confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?", + "create_job": "Crear tasca", "crontab_guru": "Crontab Guru", "disable_login": "Deshabiliteu l'inici de sessió", "disabled": "Deshabilitat", @@ -49,27 +51,37 @@ "external_library_created_at": "Llibreria externa (creada el {date})", "external_library_management": "Gestió de llibreries externes", "face_detection": "Detecció de cares", - "face_detection_description": "Detecta les cares fent servir aprenentatge automàtic. Per a videos només és té en compte la miniatura. \"Tot\" reprocessa tots els elements. \"Pendent\" encua els elements que encar no han estat processats. Les cares detectades s'encuaran per al Reconeixement Facial després de completar la Detecció Facial, tot agrupant-les entre noves persones o les ja existents.", - "facial_recognition_job_description": "Agrupa les cares detectades per persona. Aquest pas s'executa després de completar la detecció de cares. \"Tot\" reagrupa totes les cares. \"Pendent\" encua les cares que no tenen cap persona assignada.", + "face_detection_description": "Detecta les cares fent servir aprenentatge automàtic. Per a videos només és té en compte la miniatura. \"Actualitzar\" reprocessa tots els elements. \"Resetejar\" esborra tota la informació de cares actuals. \"Pendent\" afegeix a la cua els elements que encara no han estat processats. Les cares detectades s'afegiran a la cua per al Reconeixement Facial després de completar la Detecció Facial, tot agrupant-les entre noves persones o les ja existents.", + "facial_recognition_job_description": "Agrupa les cares detectades per persona. Aquest pas s'executa després de completar la detecció de cares. \"Resetejar\" reagrupa totes les cares. \"Pendent\" afegeix a la cua les cares que no tenen cap persona assignada.", "failed_job_command": "La comanda {command} ha fallat per la tasca: {job}", "force_delete_user_warning": "COMPTE: Aquesta acció eliminara immediatament l'usuari i els seus elements. Aquesta acció és irreversible i els fitxers no es poden recuperar.", "forcing_refresh_library_files": "Força l'actualització de tots els fitxers de les biblioteques", + "image_format": "Format", "image_format_description": "WebP genera fitxers més petits que JPEG, però codifica més lentament.", "image_prefer_embedded_preview": "Prefereix vista prèvia incrustada", "image_prefer_embedded_preview_setting_description": "Empra vista prèvia incrustada en les fotografies RAW com a entrada per al processament d'imatge, quan sigui possible. Aquesta acció pot produir colors més acurats en algunes imatges, però la qualitat de la vista prèvia depèn de la càmera i la imatge pot tenir més artefactes de compressió.", "image_prefer_wide_gamut": "Prefereix àmplia gamma", "image_prefer_wide_gamut_setting_description": "Uitlitza Display P3 per a les miniatures. Això preserva més bé la vitalitat de les imatges amb espais de color àmplis, però les imatges es poden veure diferent en aparells antics amb una versió antiga del navegador. Les imatges sRGB romandran com a sRGB per a evitar canvis de color.", + "image_preview_description": "Imatge de mida mitjana amb metadades eliminades, que s'utilitza quan es visualitza un sol recurs i per a l'aprenentatge automàtic", "image_preview_format": "Format de previsualització", + "image_preview_quality_description": "Vista prèvia de la qualitat de l'1 al 100. Més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l'aplicació. Establir un valor baix pot afectar la qualitat de l'aprenentatge automàtic.", "image_preview_resolution": "Resolució de previsualització", "image_preview_resolution_description": "S'empra al visualitzar una única fotografia i per a l'Aprenentatge Automàtic. L'alta resolució por preservar més detalls però es triga més a codificar, té fitxers més pesats i pot reduir la resposta de l'aplicació.", + "image_preview_title": "Paràmetres de previsualització", "image_quality": "Qualitat", "image_quality_description": "Qualitat d'imatge de 1 a 100. Un valor més alt millora la qualitat però genera fitxers més pesats.", + "image_resolution": "Resolució", + "image_resolution_description": "Les resolucions més altes poden conservar més detalls però triguen més a codificar-se, tenen mides de fitxer més grans i poden reduir la capacitat de resposta de l'aplicació.", "image_settings": "Configuració d'imatges", "image_settings_description": "Gestiona la qualitat i resolució de les imatges generades", + "image_thumbnail_description": "Miniatura petita amb metadades eliminades, que s'utilitza quan es visualitzen grups de fotos com la línia de temps principal", "image_thumbnail_format": "Format de la miniatura", + "image_thumbnail_quality_description": "Qualitat de miniatura d'1 a 100. Més alt és millor, però produeix fitxers més grans i pot reduir la capacitat de resposta de l'aplicació.", "image_thumbnail_resolution": "Resolució de la miniatura", "image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.", + "image_thumbnail_title": "Configuració de miniatures", "job_concurrency": "{job} concurrència", + "job_created": "Tasca creada", "job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.", "job_settings": "Configuració de les tasques", "job_settings_description": "Gestiona la concurrència de tasques", @@ -129,16 +141,21 @@ "map_enable_description": "Habilita característiques del mapa", "map_gps_settings": "Configuració de mapa i GPS", "map_gps_settings_description": "Gestiona la configuració de mapa i GPS (Geocodificació inversa)", + "map_implications": "La funció mapa depèn del servei extern de tesel·les (tiles.immich.cloud)", "map_light_style": "Tema clar", "map_manage_reverse_geocoding_settings": "Gestiona els paràmetres de geocodificació inversa", "map_reverse_geocoding": "Geocodificació inversa", "map_reverse_geocoding_enable_description": "Habilita la geocodificació inversa", "map_reverse_geocoding_settings": "Configuració de Geocodificació Inversa", - "map_settings": "Configuració del mapa i GPS", + "map_settings": "Mapa", "map_settings_description": "Gestiona la configuració del mapa", "map_style_description": "URL a un tema del mapa style.json", "metadata_extraction_job": "Extreure metadades", - "metadata_extraction_job_description": "Extreu l'informació de metadades de cada element, com per exemple el GPS i la resolució", + "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", + "metadata_faces_import_setting_description": "Importar cares des de les metadades EXIF de les imatges i arxius auxiliars", + "metadata_settings": "Configuració de les metadades", + "metadata_settings_description": "Administrar la configuració de les metadades", "migration_job": "Migració", "migration_job_description": "Migra les miniatures d'elements i cares cap a la nova estructura de carpetes", "no_paths_added": "Cap camí afegit", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "NOTA: Això és irreversible!", "note_unlimited_quota": "Nota: Intruduïu 0 per a quota il·limitada", "notification_email_from_address": "Des de l'adreça", - "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", + "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", "notification_email_host_description": "Amfitrió del servidor de correu electrònic (p.ex. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora els errors de certificat", "notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL de l'emissor", "oauth_mobile_redirect_uri": "URI de redirecció mòbil", "oauth_mobile_redirect_uri_override": "Sobreescriu l'URI de redirecció mòbil", - "oauth_mobile_redirect_uri_override_description": "Habilita quan 'app.immich:/' és una URI de redirecció invàlida.", + "oauth_mobile_redirect_uri_override_description": "Habilita quan el proveïdor d'OAuth no permet una URI mòbil, com ara '{callback}'", "oauth_profile_signing_algorithm": "Algoritme de signatura del perfil", "oauth_profile_signing_algorithm_description": "Algoritme utilitzat per signar el perfil d’usuari.", "oauth_scope": "Abast", @@ -193,19 +210,22 @@ "password_settings": "Inici de sessió amb contrasenya", "password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya", "paths_validated_successfully": "Tots els camins han estat validats amb èxit", + "person_cleanup_job": "Neteja de persona", "quota_size_gib": "Tamany de la quota (GiB)", "refreshing_all_libraries": "Actualitzant totes les biblioteques", "registration": "Registre d'administrador", "registration_description": "Com que ets el primer usuari del sistema, seràs designat com a administrador i seràs responsable de les tasques administratives. També seràs l'encarregat de crear usuaris addicionals.", - "removing_offline_files": "Eliminant fitxers fora de línia", + "removing_deleted_files": "Eliminant fitxers fora de línia", "repair_all": "Reparar tot", - "repair_matched_items": "Coincidència {count, plural, one {# item} other {# items}}", - "repaired_items": "Corregit {count, plural, one {# item} other {# items}}", + "repair_matched_items": "Coincidència {count, plural, one {# element} other {# elements}}", + "repaired_items": "Corregit {count, plural, one {# element} other {# elements}}", "require_password_change_on_login": "Requerir que l'usuari canviï la contrasenya en el primer inici de sessió", "reset_settings_to_default": "Restablir configuracions per defecte", "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", + "scanning_library": "Escanejant biblioteca", "scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats", "scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous", + "search_jobs": "Tasques de cerca...", "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)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats", "storage_template_user_label": "{label} és l'etiqueta d'emmagatzematge de l'usuari", "system_settings": "Configuració del sistema", + "tag_cleanup_job": "Neteja d'etiqueta", "theme_custom_css_settings": "CSS personalitzat", "theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.", "theme_settings": "Configuració del tema", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Acceleració de maquinari", "transcoding_hardware_acceleration_description": "Experimental. Molt més ràpid, però tindrà una qualitat més baixa amb la mateixa taxa de bits", "transcoding_hardware_decoding": "Descodificació de maquinari", - "transcoding_hardware_decoding_setting_description": "S'aplica només a NVENC, QSV i RKMPP. Permet l'acceleració d'extrem a extrem en lloc d'accelerar només la codificació. És possible que no funcioni en tots els vídeos.", + "transcoding_hardware_decoding_setting_description": "Habilita l'acceleració d'extrem a extrem en lloc d'accelerar només la codificació. És possible que no funcioni en tots els vídeos.", "transcoding_hevc_codec": "Còdec HEVC", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Dispositiu de maquinari preferit", "transcoding_preferred_hardware_device_description": "S'aplica només a VAAPI i QSV. Estableix el node dri utilitzat per a la transcodificació de maquinari.", "transcoding_preset_preset": "Preestablert (-preset)", - "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a \"més ràpides\".", + "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a 'més ràpides'.", "transcoding_reference_frames": "Fotogrames de referència", "transcoding_reference_frames_description": "El nombre de fotogrames a fer referència en comprimir un fotograma determinat. Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. 0 estableix aquest valor automàticament.", "transcoding_required_description": "Només vídeos que no tenen un format acceptat", @@ -307,7 +328,8 @@ "trash_settings_description": "Gestiona la configuració de la paperera", "untracked_files": "Fitxers sense seguiment", "untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error", - "user_delete_delay": "El compte i els recursos de {user} es programaran per a la supressió permanent en {delay, plural, one {# day} other {# days}}.", + "user_cleanup_job": "Neteja d'usuari", + "user_delete_delay": "El compte i els recursos de {user} es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.", "user_delete_delay_settings": "Retard de la supressió", "user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.", "user_delete_immediately": "El compte i els recursos de {user} es posaran a la cua per suprimir-los permanentment immediatament.", @@ -320,7 +342,8 @@ "user_settings": "Configuració d'usuaris", "user_settings_description": "Gestiona la configuració dels usuaris", "user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.", - "version_check_enabled_description": "Activa sol·licituds periòdiques a GitHub per comprovar si hi ha versions noves", + "version_check_enabled_description": "Activa la comprovació de la versió", + "version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb github.com", "version_check_settings": "Comprovació de versió", "version_check_settings_description": "Activa/desactiva la notificació de nova versió", "video_conversion_job": "Transcodificació de vídeos", @@ -336,7 +359,8 @@ "album_added": "Àlbum afegit", "album_added_notification_setting_description": "Rep una notificació per correu quan siguis afegit a un àlbum compartit", "album_cover_updated": "Portada de l'àlbum actualitzada", - "album_delete_confirmation": "N'esteu segur que voleu suprimir l'àlbum {album}?\nSi aquest àlbum és compartit, altres usuaris no hi podran accedir més.", + "album_delete_confirmation": "Esteu segur que voleu suprimir l'àlbum {album}?", + "album_delete_confirmation_description": "Si aquest àlbum es comparteix, els altres usuaris ja no podran accedir-hi.", "album_info_updated": "Informació de l'àlbum actualitzada", "album_leave": "Sortir de l'àlbum?", "album_leave_confirmation": "N'esteu segur que voleu sortir de {album}?", @@ -351,7 +375,7 @@ "album_user_removed": "{user} eliminat", "album_with_link_access": "Permet que qualsevol persona que tingui l'enllaç vegi fotos i persones d'aquest àlbum.", "albums": "Àlbums", - "albums_count": "{count, plural, one {{count, number} àlbum} other {{count, number} àlbums}}", + "albums_count": "{count, plural, one {{count, number} Àlbum} other {{count, number} Àlbums}}", "all": "Tots", "all_albums": "Tots els àlbum", "all_people": "Tota la gent", @@ -360,6 +384,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", + "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.", "api_key_empty": "El nom de la clau de l'API no pot estar buit", @@ -381,21 +406,22 @@ "asset_has_unassigned_faces": "L'element té cares no assignades", "asset_hashing": "Hashing...", "asset_offline": "Element fora de línia", - "asset_offline_description": "Aquest element està fora de línia. L'Immich no pot accedir a la seva ubicació. Si us plau, assegureu-vos que l'actiu està disponible i després torneu la llibreria.", + "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...", "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", - "assets_added_to_name_count": "S'ha afegit {count, plural, one {# asset} other {# assets}} a {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {Un element} other {# elements}}", - "assets_moved_to_trash_count": "{count, plural, one {Un element mogut} other {# elements moguts}} a la paperera", - "assets_permanently_deleted_count": "{count, plural, one {Un element esborrat} other {# elements esborrats}} permanentment", - "assets_removed_count": "{count, plural, one {Un element eliminat} other {# elements eliminats}}", - "assets_restore_confirmation": "Esteu segurs que voleu restaurar tots els teus actius? Aquesta acció no es pot desfer!", - "assets_restored_count": "{count, plural, one {Un element restaurat} other {# elements restaurats}}", - "assets_trashed_count": "{count, plural, one {Un element enviat} other {# elements enviats}} a la paperera", + "assets_added_to_name_count": "{count, plural, one {S'ha afegit # recurs} other {S'han afegit # recursos}} a {hasName, select, true {{name}} other {new album}}", + "assets_count": "{count, plural, one {# recurs} other {# recursos}}", + "assets_moved_to_trash_count": "{count, plural, one {# recurs mogut} other {# recursos moguts}} a la paperera", + "assets_permanently_deleted_count": "{count, plural, one {# recurs esborrat} other {# recursos esborrats}} permanentment", + "assets_removed_count": "{count, plural, one {# element eliminat} other {# elements eliminats}}", + "assets_restore_confirmation": "Esteu segurs que voleu restaurar tots els teus actius? Aquesta acció no es pot desfer! Tingueu en compte que els recursos fora de línia no es poden restaurar d'aquesta manera.", + "assets_restored_count": "{count, plural, one {# element restaurat} other {# elements restaurats}}", + "assets_trashed_count": "{count, plural, one {# element enviat} other {# elements enviats}} a la paperera", "assets_were_part_of_album_count": "{count, plural, one {L'element ja és} other {Els elements ja són}} part de l'àlbum", "authorized_devices": "Dispositius autoritzats", "back": "Enrere", @@ -404,11 +430,12 @@ "birthdate_saved": "Data de naixement guardada amb èxit", "birthdate_set_description": "La data de naixement s'utilitza per calcular l'edat d'aquesta persona en el moment d'una foto.", "blurred_background": "Fons difuminat", + "bugs_and_feature_requests": "Errors i sol·licituds de funcions", "build": "Construeix", "build_image": "Construeix la imatge", - "bulk_delete_duplicates_confirmation": "Esteu segur que voleu suprimir de manera massiva {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!", - "bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això resoldrà tots els grups duplicats sense eliminar res.", - "bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.", + "bulk_delete_duplicates_confirmation": "Esteu segur que voleu suprimir de manera massiva {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!", + "bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això resoldrà tots els grups duplicats sense eliminar res.", + "bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.", "buy": "Comprar Immich", "camera": "Càmera", "camera_brand": "Marca de la càmera", @@ -440,8 +467,11 @@ "clear_all_recent_searches": "Esborra totes les cerques recents", "clear_message": "Neteja el missatge", "clear_value": "Neteja el valor", + "clockwise": "En sentit horari", "close": "Tanca", + "collapse": "Tanca", "collapse_all": "Redueix-ho tot", + "color": "Color", "color_theme": "Tema de color", "comment_deleted": "Comentari esborrat", "comment_options": "Opcions de comentari", @@ -464,8 +494,8 @@ "copy_password": "Còpia la contrasenya", "copy_to_clipboard": "Copiar al porta-retalls", "country": "País", - "cover": "", - "covers": "", + "cover": "Portada", + "covers": "Portades", "create": "Crea", "create_album": "Crear un àlbum", "create_library": "Crea una llibreria", @@ -475,6 +505,8 @@ "create_new_person": "Crea una nova persona", "create_new_person_hint": "Assigna els elements seleccionats a una persona nova", "create_new_user": "Crea un usuari nou", + "create_tag": "Crear etiqueta", + "create_tag_description": "Crear una nova etiqueta. Per les etiquetes aniuades, escriu la ruta comperta de l'etiqueta, incloses les barres diagonals.", "create_user": "Crea un usuari", "created": "Creat", "current_device": "Dispositiu actual", @@ -495,16 +527,20 @@ "delete_api_key_prompt": "Esteu segurs que voleu eliminar aquesta clau API?", "delete_duplicates_confirmation": "Esteu segurs que voleu eliminar aquests duplicats permanentment?", "delete_key": "Suprimeix la clau", - "delete_library": "Suprimeix la llibreria", + "delete_library": "Suprimeix la Llibreria", "delete_link": "Esborra l'enllaç", "delete_shared_link": "Odstranit sdílený odkaz", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Estàs segur que vols eliminar l'etiqueta {tagName}?", "delete_user": "Suprimeix l'usuari", "deleted_shared_link": "Suprimeix l'enllaç compartit", + "deletes_missing_assets": "Elimina els actius que falten del disc", "description": "Descripció", "details": "Detalls", "direction": "Direcció", "disabled": "Desactivat", "disallow_edits": "No permetre les edicions", + "discord": "Discord", "discover": "Descobreix", "dismiss_all_errors": "Descarta tots els errors", "dismiss_error": "Descarta l'error", @@ -513,9 +549,12 @@ "display_original_photos": "Mostra les fotografies originals", "display_original_photos_setting_description": "Preferiu mostrar la foto original quan visualitzeu un recurs en lloc de miniatures quan el recurs original és compatible amb el web. Això pot provocar una velocitat de visualització de fotos més lenta.", "do_not_show_again": "No tornis a mostrar aquest missatge", + "documentation": "Documentació", "done": "Fet", - "download": "Baixar", - "download_settings": "Baixar", + "download": "Descarregar", + "download_include_embedded_motion_videos": "Vídeos incrustats", + "download_include_embedded_motion_videos_description": "Incloure vídeos incrustats en fotografies en moviment com un arxiu separat", + "download_settings": "Descarregar", "download_settings_description": "Gestioneu la configuració relacionada amb la descàrrega de recursos", "downloading": "Baixant", "downloading_asset_filename": "Descarregant l'element {filename}", @@ -544,10 +583,15 @@ "edit_location": "Edita ubicació", "edit_name": "Edita el nom", "edit_people": "Edita la gent", + "edit_tag": "Editar etiqueta", "edit_title": "Edita títol", "edit_user": "Edita l'usuari", "edited": "Editat", "editor": "Editor", + "editor_close_without_save_prompt": "No es desaran els canvis", + "editor_close_without_save_title": "Tancar l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Relació d'aspecte", + "editor_crop_tool_h2_rotation": "Rotació", "email": "Correu electrònic", "empty": "", "empty_album": "", @@ -592,10 +636,10 @@ "failed_to_unstack_assets": "No s'han pogut desapilar els elements", "import_path_already_exists": "Aquest camí d'importació ja existeix.", "incorrect_email_or_password": "Correu electrònic o contrasenya incorrectes", - "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} no ha pogut validar", + "paths_validation_failed": "{paths, plural, one {# ruta} other {# rutes}} no ha pogut validar", "profile_picture_transparent_pixels": "Les fotos de perfil no poden tenir píxels transparents. Per favor, feu zoom in, mogueu la imatge o ambdues.", "quota_higher_than_disk_size": "Heu establert una quota més gran que la mida de disc", - "repair_unable_to_check_items": "No es pot comprovar {count, select, one {item} other {items}}", + "repair_unable_to_check_items": "No es pot comprovar {count, select, one {l'element} other {els elements}}", "unable_to_add_album_users": "No es poden afegir usuaris a l'àlbum", "unable_to_add_assets_to_shared_link": "No s'han pogut afegir els elements a l'enllaç compartit", "unable_to_add_comment": "No es pot afegir el comentari", @@ -604,13 +648,13 @@ "unable_to_add_partners": "No es poden afegir companys", "unable_to_add_remove_archive": "No s'ha pogut {archived, select, true {eliminar l'element de} other {afegir l'element a}} l'arxiu", "unable_to_add_remove_favorites": "No s'ha pogut {favorite, select, true {afegir l'element als} other {eliminar l'element dels}} preferits", - "unable_to_archive_unarchive": "No es pot {archived, select, true {archive} other {unarchive}}", + "unable_to_archive_unarchive": "No es pot {archived, select, true {arxivar} other {desarxivar}}", "unable_to_change_album_user_role": "No es pot canviar el rol d'usuari de l'àlbum", "unable_to_change_date": "No es pot canviar la data", "unable_to_change_favorite": "No es pot canviar el favorit per a aquest recurs", "unable_to_change_location": "No es pot canviar la ubicació", "unable_to_change_password": "No es pot canviar la contrasenya", - "unable_to_change_visibility": "No es pot canviar la visibilitat de {count, plural, one {# person} other {# people}}", + "unable_to_change_visibility": "No es pot canviar la visibilitat de {count, plural, one {# persona} other {# persones}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "No es pot completar l'inici de sessió OAuth", @@ -637,6 +681,7 @@ "unable_to_get_comments_number": "No es pot obtenir el nombre de comentaris", "unable_to_get_shared_link": "No s'ha pogut obtenir l'enllaç compartit", "unable_to_hide_person": "No es pot amagar la persona", + "unable_to_link_motion_video": "No es pot enllaçar el vídeo en moviment", "unable_to_link_oauth_account": "No es pot enllaçar el compte OAuth", "unable_to_load_album": "No es pot carregar l'àlbum", "unable_to_load_asset_activity": "No es pot carregar l'activitat dels recursos", @@ -646,15 +691,15 @@ "unable_to_log_out_device": "No es pot tancar la sessió del dispositiu", "unable_to_login_with_oauth": "No es pot iniciar sessió amb OAuth", "unable_to_play_video": "No es pot reproduir el vídeo", - "unable_to_reassign_assets_existing_person": "No es poden reassignar recursos a {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_existing_person": "No es poden reassignar recursos a {name, select, null {una persona existent} other {{name}}}", "unable_to_reassign_assets_new_person": "No es poden reassignar recursos a una persona nova", "unable_to_refresh_user": "No es pot actualitzar l'usuari", "unable_to_remove_album_users": "No es poden eliminar usuaris de l'àlbum", "unable_to_remove_api_key": "No es pot eliminar la clau de l'API", "unable_to_remove_assets_from_shared_link": "No es poden eliminar recursos de l'enllaç compartit", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "No es poden eliminar els fitxers fora de línia", "unable_to_remove_library": "No es pot eliminar la biblioteca", - "unable_to_remove_offline_files": "No es poden eliminar els fitxers fora de línia", "unable_to_remove_partner": "No es pot eliminar company/a", "unable_to_remove_reaction": "No es pot eliminar la reacció", "unable_to_remove_user": "", @@ -677,6 +722,7 @@ "unable_to_submit_job": "No es pot enviar la tasca", "unable_to_trash_asset": "No es pot eliminar el recurs a la paperera", "unable_to_unlink_account": "No es pot desenllaçar el compte", + "unable_to_unlink_motion_video": "No es pot desvincular el vídeo en moviment", "unable_to_update_album_cover": "No es pot actualitzar la portada de l'àlbum", "unable_to_update_album_info": "No es pot actualitzar la informació de l'àlbum", "unable_to_update_library": "No es pot actualitzar la biblioteca", @@ -690,12 +736,14 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "Surt de la presentació de diapositives", "expand_all": "Ampliar-ho tot", "expire_after": "Caduca després de", "expired": "Caducat", "expires_date": "Caduca el {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exporta", "export_as_json": "Exportar com a JSON", "extension": "Extensió", @@ -709,14 +757,18 @@ "feature": "", "feature_photo_updated": "Foto destacada actualitzada", "featurecollection": "", + "features": "Característiques", + "features_setting_description": "Administrar les funcions de l'aplicació", "file_name": "Nom de l'arxiu", "file_name_or_extension": "Nom de l'arxiu o extensió", - "filename": "Nom de l'arxiu", + "filename": "Nom del fitxer", "files": "", "filetype": "Tipus d'arxiu", "filter_people": "Filtra persones", "find_them_fast": "Trobeu-los ràpidament pel nom amb la cerca", "fix_incorrect_match": "Corregiu la coincidència incorrecta", + "folders": "Carpetes", + "folders_feature_description": "Explorar la vista de carpetes per les fotos i vídeos del sistema d'arxius", "force_re-scan_library_files": "Força a tornar a escanejar tots els fitxers de la biblioteca", "forward": "Endavant", "general": "General", @@ -751,11 +803,11 @@ "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} pres/a a {city}, {country} amb {person1}, {person2}, i {person3} el {date}", "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} pres/a a {city}, {country} amb {person1}, {person2}, i {additionalCount, number} altres el {date}", "img": "", - "immich_logo": "", + "immich_logo": "Logotip d'Immich", "immich_web_interface": "Interfície web Immich", "import_from_json": "Importar des de JSON", "import_path": "Ruta d'importació", - "in_albums": "A {count, plural, one {# album} other {# albums}}", + "in_albums": "A {count, plural, one {# àlbum} other {# àlbums}}", "in_archive": "En arxiu", "include_archived": "Incloure arxivats", "include_shared_albums": "Inclou àlbums compartits", @@ -774,6 +826,7 @@ "job_settings_description": "", "jobs": "Tasques", "keep": "Mantenir", + "keep_all": "Mantenir-ho tot", "keyboard_shortcuts": "Dreceres de teclat", "language": "Idioma", "language_setting_description": "Seleccioneu el vostre idioma", @@ -800,6 +853,7 @@ "license_trial_info_3": "{accountAge, plural, one {# dia} other {# dies}}", "light": "Llum", "like_deleted": "M'agrada suprimit", + "link_motion_video": "Enllaçar vídeo en moviment", "link_options": "Opcions d'enllaç", "link_to_oauth": "Enllaç a OAuth", "linked_oauth_account": "Compte OAuth enllaçat", @@ -818,8 +872,9 @@ "look": "Aspecte", "loop_videos": "Vídeos en bucle", "loop_videos_description": "Habilita la reproducció en bucle del vídeo en els detalls.", + "main_branch_warning": "Esteu usant una versió de desenvolupaent. Recomanem fer servir una versió publicada!", "make": "Fabricant", - "manage_shared_links": "Spravovat sdílené odkazy", + "manage_shared_links": "Administrar enllaços compartits", "manage_sharing_with_partners": "Gestiona la compartició amb els companys", "manage_the_app_settings": "Gestioneu la configuració de l'aplicació", "manage_your_account": "Gestiona el teu compte", @@ -842,7 +897,7 @@ "merge_people_limit": "Només pots combinar fins a 5 cares alhora", "merge_people_prompt": "Vols combinar aquestes persones? Aquesta acció és irreversible.", "merge_people_successfully": "Persones combinades amb èxit", - "merged_people_count": "Combinades {count, plural, one {# person} other {# people}}", + "merged_people_count": "Combinades {count, plural, one {# persona} other {# persones}}", "minimize": "Minimitza", "minute": "Minut", "missing": "Restants", @@ -887,17 +942,21 @@ "notifications": "Notificacions", "notifications_setting_description": "Gestiona les notificacions", "oauth": "OAuth", + "official_immich_resources": "Recursos oficials d'Immich", "offline": "Fora de línia", "offline_paths": "Rutes fora de línia", "offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.", "ok": "D'acord", "oldest_first": "El més vell primer", + "onboarding": "Incorporació", + "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", "onboarding_welcome_user": "Benvingut, {user}", "online": "En línia", "only_favorites": "Només preferits", "only_refreshes_modified_files": "Només actualitza els fitxers modificats", + "open_in_map_view": "Obrir a la vista del mapa", "open_in_openstreetmap": "Obre a OpenStreetMap", "open_the_search_filters": "Obriu els filtres de cerca", "options": "Opcions", @@ -931,7 +990,8 @@ "paused": "En pausa", "pending": "Pendent", "people": "Persones", - "people_edits_count": "{count, plural, one {Una persona editada} other {# persones editades}}", + "people_edits_count": "{count, plural, one {# persona editada} other {# persones editades}}", + "people_feature_description": "Explorar fotos i vídeos agrupades per persona", "people_sidebar_description": "Mostrar un enllaç a Persones a la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Avís d'eliminació permanent", @@ -940,13 +1000,13 @@ "permanently_delete_assets_count": "Eliminar permanentment {count, plural, one {l'element} other {els elements}}", "permanently_delete_assets_prompt": "Esteu segur que voleu suprimir permanentment {count, plural, one {aquest recurs?} other {aquests # recursos?}} Això també {count, plural, one {el} other {els}} suprimirà del seu àlbum.", "permanently_deleted_asset": "Element eliminat permanentment", - "permanently_deleted_assets_count": "{count, plural, one {S'ha eliminat un element} other {S'han eliminat # elements}} permanentment", + "permanently_deleted_assets_count": "{count, plural, one {S'ha eliminat # element} other {S'han eliminat # elements}} permanentment", "person": "Persona", "person_hidden": "{name}{hidden, select, true { (ocultat)} other {}}", "photo_shared_all_users": "Sembla que has compartit les teves fotos amb tots els usuaris o no tens cap usuari amb qui compartir-les.", "photos": "Fotos", "photos_and_videos": "Fotos i vídeos", - "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotos}}", + "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", "photos_from_previous_years": "Fotos d'anys anteriors", "pick_a_location": "Triar una ubicació", "place": "Lloc", @@ -963,10 +1023,12 @@ "previous_memory": "Memòria anterior", "previous_or_next_photo": "Foto anterior o següent", "primary": "Primària", + "privacy": "Privacitat", "profile_image_of_user": "Imatge de perfil de {user}", "profile_picture_set": "Imatge de perfil configurada.", "public_album": "Àlbum públic", "public_share": "Compartit públicament", + "purchase_account_info": "Contribuent", "purchase_activated_subtitle": "Gràcies per donar suport a Immich i al programari de codi obert", "purchase_activated_time": "Activat el {date, date}", "purchase_activated_title": "La teva clau s'ha activat correctament", @@ -979,6 +1041,8 @@ "purchase_button_select": "Seleccioneu", "purchase_failed_activation": "No s'ha pogut activar! Si us plau, comproveu el vostre correu electrònic per trobar la clau de producte correcta!", "purchase_individual_description_1": "Per a un particular", + "purchase_individual_description_2": "Estat de la contribució", + "purchase_individual_title": "Individual", "purchase_input_suggestion": "Tens una clau de producte? Introduïu la clau a continuació", "purchase_license_subtitle": "Compra Immich per donar suport al desenvolupament continuat del servei", "purchase_lifetime_description": "Compra de per vida", @@ -993,25 +1057,32 @@ "purchase_remove_server_product_key": "Elimina la clau de producte del servidor", "purchase_remove_server_product_key_prompt": "Esteu segur que voleu eliminar la clau de producte del servidor?", "purchase_server_description_1": "Per a tot el servidor", + "purchase_server_description_2": "Estat del contribuent", "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clau de producte del servidor la gestiona l'administrador", "range": "", + "rating": "Valoració", + "rating_clear": "Esborrar valoració", + "rating_count": "{count, plural, one {# estrella} other {# estrelles}}", + "rating_description": "Mostrar la valoració EXIF al panell d'informació", "raw": "", "reaction_options": "Opcions de reacció", "read_changelog": "Llegeix el registre de canvis", "reassign": "Reassignar", - "reassigned_assets_to_existing_person": "S'ha reassignat {count, plural, one {# recurs} other {# recursos}} a {name, select, null {una persona existent} other {{name}}}", - "reassigned_assets_to_new_person": "S'ha reassignat {count, plural, one {# recurs} other {# recursos}} a una persona nova", + "reassigned_assets_to_existing_person": "{count, plural, one {S'ha reassignat # recurs} other {S'han reassignat # recursos}} a {name, select, null {una persona existent} other {{name}}}", + "reassigned_assets_to_new_person": "{count, plural, one {S'ha reassignat # recurs} other {S'han reassignat # recursos}} a una persona nova", "reassing_hint": "Assignar els elements seleccionats a una persona existent", "recent": "Recent", "recent_searches": "Cerques recents", "refresh": "Actualitzar", "refresh_encoded_videos": "Actualitza vídeos codificats", + "refresh_faces": "Actualitzar cares", "refresh_metadata": "Actualitzar les metadades", "refresh_thumbnails": "Actualitzar la miniatura", "refreshed": "Actualitzat", - "refreshes_every_file": "Actualitza tots els fitxers", + "refreshes_every_file": "Torna a llegir tots els fitxers existents i nous", "refreshing_encoded_video": "S'està actualitzant el vídeo codificat", + "refreshing_faces": "Refrescant cares", "refreshing_metadata": "Actualitzant les metadades", "regenerating_thumbnails": "Regenerant les miniatures", "remove": "Eliminar", @@ -1019,16 +1090,17 @@ "remove_assets_shared_link_confirmation": "Esteu segur que voleu eliminar {count, plural, one {# recurs} other {# recursos}} d'aquest enllaç compartit?", "remove_assets_title": "Eliminar els elements?", "remove_custom_date_range": "Elimina l'interval de dates personalitzat", + "remove_deleted_assets": "Suprimeix fitxers fora de línia", "remove_from_album": "Treu de l'àlbum", "remove_from_favorites": "Eliminar dels preferits", "remove_from_shared_link": "Eliminar de l'enllaç compartit", - "remove_offline_files": "Suprimeix fitxers fora de línia", "remove_user": "Eliminar l'usuari", "removed_api_key": "Eliminada la clau d'API: {name}", "removed_from_archive": "Eliminat de l'arxiu", "removed_from_favorites": "Eliminat dels preferits", - "removed_from_favorites_count": "{count, plural, other {Removed #}} dels preferits", - "rename": "Canvia el nom", + "removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits", + "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# actiu} other {# actius}}", + "rename": "Canviar nom", "repair": "Reparació", "repair_no_results_message": "Els fitxers sense seguiment i que falten es mostraran aquí", "replace_with_upload": "Substituir amb una pujada", @@ -1050,6 +1122,7 @@ "retry_upload": "Torna a provar de pujar", "review_duplicates": "Revisar duplicats", "role": "Rol", + "role_editor": "Editor", "role_viewer": "Visor", "save": "Desa", "saved_api_key": "Clau d'API guardada", @@ -1058,6 +1131,7 @@ "say_something": "Digues quelcom", "scan_all_libraries": "Escanejar totes les llibreries", "scan_all_library_files": "Re-escanejar tots els fitxers de la llibreria", + "scan_library": "Escaneja", "scan_new_library_files": "Escanejar nous fitxers de la llibreria", "scan_settings": "Configuració d'escaneig", "scanning_for_album": "S'està buscant l'àlbum...", @@ -1065,6 +1139,7 @@ "search_albums": "Buscar àlbums", "search_by_context": "Buscar per context", "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...", @@ -1072,9 +1147,12 @@ "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_settings": "Configuració de cerca", "search_state": "Buscar per regió...", + "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", "search_type": "Buscar per tipus", "search_your_photos": "Cerca les teves fotos", @@ -1116,6 +1194,7 @@ "shared_by_user": "Compartit per {user}", "shared_by_you": "Compartit per tu", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opcions d'enllaços compartits", "shared_links": "Enllaços compartits", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}", "shared_with_partner": "Compartit amb {partner}", @@ -1124,6 +1203,7 @@ "sharing_sidebar_description": "Mostra un enllaç a Compartit a la barra lateral", "shift_to_permanent_delete": "premeu ⇧ per suprimir el recurs permanentment", "show_album_options": "Mostra les opcions d'àlbum", + "show_albums": "Mostrar àlbums", "show_all_people": "Veure totes les persones", "show_and_hide_people": "Mostra i amaga persones", "show_file_location": "Mostra l'ubicació del fitxer", @@ -1138,11 +1218,18 @@ "show_person_options": "Mostra opcions de la persona", "show_progress_bar": "Mostra barra de progrés", "show_search_options": "Mostra opcions de cerca", + "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", "shuffle": "Mescla", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostra un enllaç a la vista a la barra lateral", "sign_out": "Tanca sessió", "sign_up": "Registrar-se", "size": "Mida", "skip_to_content": "Salta al contingut", + "skip_to_folders": "Anar a carpetes", + "skip_to_tags": "Anar a etiquetes", "slideshow": "Diapositives", "slideshow_settings": "Configuració de diapositives", "sort_albums_by": "Ordena àlbums per...", @@ -1152,7 +1239,10 @@ "sort_oldest": "Foto més antiga", "sort_recent": "Foto més recent", "sort_title": "Títol", + "source": "Font", "stack": "Apila", + "stack_duplicates": "Aplicar duplicats", + "stack_select_one_photo": "Selecciona una imatge principal per la pila", "stack_selected_photos": "Apila les fotos seleccionades", "stacked_assets_count": "Apilats {count, plural, one {# element} other {# elements}}", "stacktrace": "Traça de pila", @@ -1170,22 +1260,35 @@ "submit": "Envia", "suggestions": "Suggeriments", "sunrise_on_the_beach": "Albada a la platja", + "support": "Suport", + "support_and_feedback": "Suport i comentaris", + "support_third_party_description": "La vostra instal·lació immich la va empaquetar un tercer. Els problemes que experimenteu poden ser causats per aquest paquet així que, si us plau, plantegeu els poblemes amb ells en primer lloc mitjançant els enllaços següents.", "swap_merge_direction": "Canvia la direcció d'unió", "sync": "Sincronitza", + "tag": "Etiqueta", + "tag_assets": "Etiquetar actius", + "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_updated": "Etiqueta actualizada: {tag}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", "theme_selection": "Selecció de tema", "theme_selection_description": "Activa automàticament el tema fosc o clar en funció de les preferències del sistema del navegador", "they_will_be_merged_together": "Es combinaran", + "third_party_resources": "Recursos de tercers", "time_based_memories": "Records basats en el temps", "timezone": "Fus horari", "to_archive": "Arxivar", "to_change_password": "Canviar la contrasenya", "to_favorite": "Prefereix", "to_login": "Iniciar sessió", + "to_parent": "Anar als pares", "to_trash": "Paperera", "toggle_settings": "Canvia configuració", - "toggle_theme": "Canvia tema", + "toggle_theme": "Alternar tema", "toggle_visibility": "Canvia visibilitat", "total_usage": "Ús total", "trash": "Paperera", @@ -1204,9 +1307,11 @@ "unknown_album": "Àlbum 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", "unnamed_album": "Àlbum sense nom", + "unnamed_album_delete_confirmation": "Segur que voleu esborrar aquest àlbum?", "unnamed_share": "Compartit sense nom", "unsaved_change": "Canvi no desat", "unselect_all": "Deselecciona-ho tot", @@ -1219,7 +1324,7 @@ "updated_password": "Contrasenya actualitzada", "upload": "Pujar", "upload_concurrency": "Concurrència de pujades", - "upload_errors": "Càrrega completada amb {count, plural, one {un error} other {# errors}}, actualitzeu la pàgina per veure els nous elements carregats.", + "upload_errors": "Càrrega completada amb {count, plural, one {# error} other {# errors}}, actualitzeu la pàgina per veure els nous elements carregats.", "upload_progress": "Restant {remaining, number} - Processat {processed, number}/{total, number}", "upload_skipped_duplicates": "{count, plural, one {S'ha omès # recurs duplicat} other {S'han omès # recursos duplicats}}", "upload_status_duplicates": "Duplicats", @@ -1245,6 +1350,8 @@ "version": "Versió", "version_announcement_closing": "El teu amic Alex", "version_announcement_message": "Hola amic, hi ha una nova versió de l'aplicació, si us plau, preneu-vos el temps per visitar les release notes i assegureu-vos que el vostre docker-compose.yml i .env estàn actualitzats per evitar qualsevol configuració incorrecta, especialment si utilitzeu WatchTower o qualsevol mecanisme que gestioni l'actualització automàtica de la vostra aplicació.", + "version_history": "Historial de versions", + "version_history_item": "Instal·lat {version} el {date}", "video": "Vídeo", "video_hover_setting": "Reprodueix la miniatura en passar el ratolí", "video_hover_setting_description": "Reprodueix la miniatura quan el ratolí plana sobre l'element. Fins i tot quan estigui deshabilitat, la reproducció s'iniciarà planant sobre el botó de reproducció.", @@ -1254,6 +1361,7 @@ "view_album": "Veure l'àlbum", "view_all": "Veure tot", "view_all_users": "Mostra tot els usuaris", + "view_in_timeline": "Mostrar a la línia de temps", "view_links": "Mostra enllaços", "view_next_asset": "Mostra el següent element", "view_previous_asset": "Mostra l'element anterior", @@ -1266,7 +1374,7 @@ "welcome": "Benvingut", "welcome_to_immich": "Benvingut a immich", "year": "Any", - "years_ago": "Fa {years, plural, one {un any} other {# anys}}", + "years_ago": "Fa {years, plural, one {# any} other {# anys}}", "yes": "Sí", "you_dont_have_any_shared_links": "No tens cap enllaç compartit", "zoom_image": "Ampliar Imatge" diff --git a/web/src/lib/i18n/cs.json b/i18n/cs.json similarity index 90% rename from web/src/lib/i18n/cs.json rename to i18n/cs.json index 58cf97f80e..12ba83b8e7 100644 --- a/web/src/lib/i18n/cs.json +++ b/i18n/cs.json @@ -28,6 +28,7 @@ "added_to_favorites_count": "Přidáno {count, number} do oblíbených", "admin": { "add_exclusion_pattern_description": "Přidání vzorů vyloučení. Podporováno je globování pomocí *, ** a ?. Chcete-li ignorovat všechny soubory v jakémkoli adresáři s názvem \"Raw\", použijte \"**/Raw/**\". Chcete-li ignorovat všechny soubory končící na \".tif\", použijte \"**/*.tif\". Chcete-li ignorovat absolutní cestu, použijte příkaz \"/path/to/ignore/**\".", + "asset_offline_description": "Tato položka externí knihovny se již na disku nenachází a byla přesunuta do koše. Pokud byl soubor přesunut v rámci knihovny, zkontrolujte časovou osu a vyhledejte nové odpovídající položku. Chcete-li tuto položku obnovit, ujistěte se, že je cesta k níže uvedenému souboru přístupná pomocí aplikace Immich a prohledejte knihovnu.", "authentication_settings": "Přihlašování", "authentication_settings_description": "Správa hesel, OAuth a dalších nastavení ověření", "authentication_settings_disable_all": "Opravdu chcete zakázat všechny metody přihlášení? Přihlašování bude úplně zakázáno.", @@ -41,6 +42,7 @@ "confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"", "confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.", "confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?", + "create_job": "Vytvořit úlohu", "crontab_guru": "Crontab Guru", "disable_login": "Zakázat přihlášení", "disabled": "Zakázáno", @@ -49,27 +51,37 @@ "external_library_created_at": "Externí knihovna (vytvořena {date})", "external_library_management": "Správa externích knihoven", "face_detection": "Detekce obličejů", - "face_detection_description": "Detekce obličejů v obrázcích pomocí strojového učení. U videí se bere v úvahu pouze miniatura. \"Vše\" znovu zpracovává všechny položky. \"Chybějící\" zařadí do fronty položky, které ještě nebyly zpracovány. Zjištěné obličeje budou po dokončení funkce Rozpoznávání obličejů zařazeny do fronty a seskupeny do stávajících nebo nových osob.", - "facial_recognition_job_description": "Seskupí nalezené obličeje do osob. Tento krok se spustí po dokončení detekce obličejů. \"Vše\" znovu seskupí všechny obličeje. \"Chybějící\" zpracuje obličeje, které nemají přiřazenou osobu.", + "face_detection_description": "Detekce obličejů v obrázcích pomocí strojového učení. U videí se bere v úvahu pouze miniatura. „Obnovit“ znovu zpracuje všechny položky. „Resetovat“ navíc vymaže všechna aktuální data obličejů. „Chybějící“ zařadí do fronty položky, které ještě nebyly zpracovány. Zjištěné obličeje budou po dokončení funkce Rozpoznávání obličejů zařazeny do fronty a seskupeny do stávajících nebo nových osob.", + "facial_recognition_job_description": "Seskupí nalezené obličeje do osob. Tento krok se spustí po dokončení detekce obličejů. „Resetovat“ znovu seskupí všechny obličeje. „Chybějící“ zpracuje obličeje, které nemají přiřazenou osobu.", "failed_job_command": "Příkaz {command} se nezdařil pro úlohu: {job}", "force_delete_user_warning": "UPOZORNĚNÍ: Tímto okamžitě odstraníte uživatele a všechny jeho položky. Tento krok nelze vrátit zpět a soubory nelze obnovit.", "forcing_refresh_library_files": "Vynucení obnovy všech souborů knihovny", + "image_format": "Formát", "image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování.", "image_prefer_embedded_preview": "Preferovat vložený náhled", "image_prefer_embedded_preview_setting_description": "Použít vložené náhledy z RAW fotografií jako vstup pro zpracování snímků, pokud jsou k dispozici. U některých snímků tak lze dosáhnout přesnějších barev, ale kvalita náhledu závisí na fotoaparátu a snímek může obsahovat více kompresních artefaktů.", "image_prefer_wide_gamut": "Preferovat široký gamut", "image_prefer_wide_gamut_setting_description": "Použít Display P3 pro miniatury. To lépe zachovává živost obrázků s širokým barevným prostorem, ale obrázky se mohou na starých zařízeních se starou verzí prohlížeče zobrazovat jinak. sRGB obrázky jsou ponechány jako sRGB, aby se zabránilo posunům barev.", + "image_preview_description": "Středně velký obrázek se zbavenými metadaty, který se používá při prohlížení jedné položky a pro strojové učení", "image_preview_format": "Formát náhledů", + "image_preview_quality_description": "Kvalita náhledu od 1 do 100. Vyšší je lepší, ale vytváří větší soubory a může snížit responzivitu aplikace. Nastavení nízké hodnoty může ovlivnit kvalitu strojového učení.", "image_preview_resolution": "Rozlišení náhledů", "image_preview_resolution_description": "Používá se při prohlížení jedné fotografie a pro strojové učení. Vyšší rozlišení mohou zachovat více detailů, ale jejich kódování trvá déle, mají větší velikost souboru a mohou snížit odezvu aplikace.", + "image_preview_title": "Náhledy", "image_quality": "Kvalita", "image_quality_description": "Kvalita obrazu od 1 do 100. Vyšší kvalita je lepší, ale vytváří větší soubory, tato volba ovlivňuje náhled a miniatury obrázků.", + "image_resolution": "Rozlišení", + "image_resolution_description": "Vyšší rozlišení mohou zachovat více detailů, ale jejich kódování trvá déle, mají větší velikost souboru a mohou snížit odezvu aplikace.", "image_settings": "Obrázky", "image_settings_description": "Správa kvality a rozlišení generovaných obrázků", + "image_thumbnail_description": "Malá miniatura s odstraněnými metadaty, který se používá při prohlížení skupin fotografií, jako je hlavní časová osa", "image_thumbnail_format": "Formát miniatur", + "image_thumbnail_quality_description": "Kvalita miniatur od 1 do 100. Vyšší je lepší, ale vytváří větší soubory a může snížit odezvu aplikace.", "image_thumbnail_resolution": "Rozlišení miniatur", "image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.", - "job_concurrency": "Souběžnost {job}", + "image_thumbnail_title": "Miniatury", + "job_concurrency": "Souběžnost úlohy {job}", + "job_created": "Úloha vytvořena", "job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.", "job_settings": "Úlohy", "job_settings_description": "Správa souběžnosti úloh", @@ -98,7 +110,7 @@ "machine_learning_clip_model_description": "Název CLIP modelu je uvedený zde. Pamatujte, že při změně modelu je nutné znovu spustit úlohu 'Chytré vyhledávání' pro všechny obrázky.", "machine_learning_duplicate_detection": "Kontrola duplicit", "machine_learning_duplicate_detection_enabled": "Povolit kontrolu duplicit", - "machine_learning_duplicate_detection_enabled_description": "Pokud je tato funkce vypnuta, budou identické položky stále duplikovány.", + "machine_learning_duplicate_detection_enabled_description": "Pokud je tato funkce vypnuta, budou identické položky stále deduplikovány.", "machine_learning_duplicate_detection_setting_description": "Použít CLIP embeddings k nalezení pravděpodobných duplicit", "machine_learning_enabled": "Povolit strojové učení", "machine_learning_enabled_description": "Pokud je vypnuto, budou všechny funkce strojového učení vypnuty bez ohledu na níže uvedená nastavení.", @@ -129,6 +141,7 @@ "map_enable_description": "Povolit funkce mapy", "map_gps_settings": "Mapa a GPS", "map_gps_settings_description": "Správa nastavení mapy a GPS (Reverzní geokódování)", + "map_implications": "Funkce mapy závisí na externí dlaždicové službě (tiles.immich.cloud)", "map_light_style": "Světlý motiv", "map_manage_reverse_geocoding_settings": "Správa nastavení Reverzního geokódování", "map_reverse_geocoding": "Reverzní geokódování", @@ -138,7 +151,11 @@ "map_settings_description": "Správa nastavení mapy", "map_style_description": "URL na style.json motivu", "metadata_extraction_job": "Extrakce metadat", - "metadata_extraction_job_description": "Získání informací o metadatech z každého snímku, jako je GPS a rozlišení", + "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", + "metadata_faces_import_setting_description": "Import obličejů z EXIF dat obrázků a sidecar souborů", + "metadata_settings": "Metadata", + "metadata_settings_description": "Správa nastavení metadat", "migration_job": "Migrace", "migration_job_description": "Migrace miniatur snímků a obličejů do nejnovější struktury složek", "no_paths_added": "Nebyly přidány žádné cesty", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "UPOZORNĚNÍ: Toto nelze později změnit!", "note_unlimited_quota": "Upozornění: Pro neomezenou kvótu zadejte 0", "notification_email_from_address": "Adresa Od", - "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", "notification_email_host_description": "Adresa e-mailového serveru (např. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorovat chyby certifikátů", "notification_email_ignore_certificate_errors_description": "Ignorovat chyby ověření certifikátu TLS (nedoporučuje se)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL vydavatele", "oauth_mobile_redirect_uri": "Mobilní přesměrování URI", "oauth_mobile_redirect_uri_override": "Přepsat mobilní přesměrování URI", - "oauth_mobile_redirect_uri_override_description": "Povolit, pokud je 'app.immich:/' neplatné URI přesměrování.", + "oauth_mobile_redirect_uri_override_description": "Povolit, pokud poskytovatel OAuth nepovoluje mobilní URI, například '{callback}'", "oauth_profile_signing_algorithm": "Algoritmus podepisování profilu", "oauth_profile_signing_algorithm_description": "Algoritmus použitý k podepsání profilu uživatele.", "oauth_scope": "Rozsah", @@ -193,19 +210,22 @@ "password_settings": "Přihlášení heslem", "password_settings_description": "Správa nastavení přihlašování pomocí hesla", "paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny", + "person_cleanup_job": "Promazání osob", "quota_size_gib": "Velikost kvóty (GiB)", "refreshing_all_libraries": "Obnovení všech knihoven", "registration": "Registrace správce", "registration_description": "Vzhledem k tomu, že jste prvním uživatelem v systému, budete přiřazen jako správce a budete zodpovědný za úkoly správy a další uživatelé budou vytvořeni vámi.", - "removing_offline_files": "Odstranění offline souborů", + "removing_deleted_files": "Odstranění offline souborů", "repair_all": "Opravit vše", "repair_matched_items": "Shoda {count, plural, one {# položky} other {# položek}}", "repaired_items": "{count, plural, one {Opravena # položka} few {Opraveny # položky} other {Opraveno # položek}}", "require_password_change_on_login": "Požadovat, aby si uživatel při prvním přihlášení změnil heslo", "reset_settings_to_default": "Obnovení výchozího nastavení", "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", + "scanning_library": "Prohledat knihovnu", "scanning_library_for_changed_files": "Hledání změněných souborů v knihovně", "scanning_library_for_new_files": "Hledání nových souborů v knihovně", + "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)://", @@ -233,13 +253,14 @@ "storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů", "storage_template_user_label": "{label} je štítek úložiště uživatele", "system_settings": "Systémová nastavení", + "tag_cleanup_job": "Promazání značek", "theme_custom_css_settings": "Vlastní CSS", "theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.", "theme_settings": "Motivy", "theme_settings_description": "Správa přizpůsobení webového rozhraní Immich", "these_files_matched_by_checksum": "Tyto soubory jsou porovnávány podle jejich kontrolních součtů", "thumbnail_generation_job": "Generování miniatur", - "thumbnail_generation_job_description": "Generování velkých, malých a rozmazaných náhledů pro každý obrázek a náhledů pro každou osobu", + "thumbnail_generation_job_description": "Generování velkých, malých a rozmazaných miniatur pro každý obrázek a miniatur pro každou osobu", "transcode_policy_description": "Zásady, kdy má být video překódováno. Videa HDR budou překódována vždy (kromě případů, kdy je překódování zakázáno).", "transcoding_acceleration_api": "API pro akceleraci", "transcoding_acceleration_api_description": "Rozhraní, které bude komunikovat se zařízením a urychlovat překódování. Toto nastavení je 'best effort': při selhání se vrátí k softwarovému překódování. VP9 může, ale nemusí fungovat v závislosti na vašem hardwaru.", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Hardwarová akcelerace", "transcoding_hardware_acceleration_description": "Experimentální; mnohem rychlejší, ale při stejném datovém toku bude mít nižší kvalitu", "transcoding_hardware_decoding": "Hardwarové dekódování", - "transcoding_hardware_decoding_setting_description": "Platí pouze pro NVENC, QSV a RKMPP. Povoluje kompletní akceleraci namísto akcelerace pouze kódování. Nemusí fungovat u všech videí.", + "transcoding_hardware_decoding_setting_description": "Povoluje kompletní akceleraci namísto akcelerace pouze kódování. Nemusí fungovat u všech videí.", "transcoding_hevc_codec": "Kodek HEVC", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Preferované hardwarové zařízení", "transcoding_preferred_hardware_device_description": "Platí pouze pro VAAPI a QSV. Nastaví dri uzel použitý pro hardwarové překódování.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Rychlost komprese. Pomalejší předvolby vytvářejí menší soubory a zvyšují kvalitu při dosažení určitého datového toku. VP9 ignoruje rychlosti vyšší než `faster`.", + "transcoding_preset_preset_description": "Rychlost komprese. Pomalejší předvolby vytvářejí menší soubory a zvyšují kvalitu při dosažení určitého datového toku. VP9 ignoruje rychlosti vyšší než 'faster'.", "transcoding_reference_frames": "Referenční snímky", "transcoding_reference_frames_description": "Počet referenčních snímků při kompresi daného snímku. Vyšší hodnoty zvyšují účinnost komprese, ale zpomalují kódování. Hodnota 0 toto nastavuje automaticky.", "transcoding_required_description": "Pouze videa, která nejsou v akceptovaném formátu", @@ -307,6 +328,7 @@ "trash_settings_description": "Správa nastavení koše", "untracked_files": "Neznámé soubory", "untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", + "user_cleanup_job": "Promazání uživatelů", "user_delete_delay": "Účet a položky uživatele {user} budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.", "user_delete_delay_settings": "Odložení odstranění", "user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.", @@ -320,7 +342,8 @@ "user_settings": "Uživatelé", "user_settings_description": "Správa nastavení uživatelů", "user_successfully_removed": "Uživatel {email} byl úspěšně odstraněn.", - "version_check_enabled_description": "Povolení pravidelných požadavků na GitHub pro kontrolu nových verzí", + "version_check_enabled_description": "Povolit kontrolu verzí", + "version_check_implications": "Kontrola verze je založena na pravidelné komunikaci s github.com", "version_check_settings": "Kontrola verze", "version_check_settings_description": "Povolení/zakázání oznámení o nové verzi", "video_conversion_job": "Překódování videí", @@ -336,7 +359,8 @@ "album_added": "Přidáno album", "album_added_notification_setting_description": "Dostávat e-mailové oznámení, když jste přidáni do sdíleného alba", "album_cover_updated": "Obal alba aktualizován", - "album_delete_confirmation": "Opravdu chcete album {album} odstranit?\nPokud je toto album sdílené, ostatní uživatelé k němu již nebudou mít přístup.", + "album_delete_confirmation": "Opravdu chcete album {album} odstranit?", + "album_delete_confirmation_description": "Pokud je toto album sdíleno, ostatní uživatelé k němu již nebudou mít přístup.", "album_info_updated": "Informace o albu aktualizovány", "album_leave": "Opustit album?", "album_leave_confirmation": "Opravdu chcete opustit {album}?", @@ -358,8 +382,9 @@ "all_videos": "Všechna videa", "allow_dark_mode": "Povolit tmavý režim", "allow_edits": "Povolit úpravy", - "allow_public_user_to_download": "Povolit veřejnosti stahování", + "allow_public_user_to_download": "Povolit veřejnosti stahovat", "allow_public_user_to_upload": "Povolit veřejnosti nahrávat", + "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.", "api_key_empty": "Název klíče API by neměl být prázdný", @@ -381,8 +406,9 @@ "asset_has_unassigned_faces": "Položka má nepřiřazené obličeje", "asset_hashing": "Hashování...", "asset_offline": "Offline položka", - "asset_offline_description": "Tato položka je offline. Immich nemá přístup k jejímu umístění. Zkontrolujte, zda je položka dostupná, a poté knihovnu znovu prohledejte.", + "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í...", "assets": "Položky", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "Do koše {count, plural, one {přesunuta # položka} few {přesunuty # položky} other {přesunuto # položek}}", "assets_permanently_deleted_count": "Trvale {count, plural, one {smazána # položka} few {smazány # položky} other {smazáno # položek}}", "assets_removed_count": "{count, plural, one {Odstraněna # položka} few {Odstraněny # položky} other {Odstraněno # položek}}", - "assets_restore_confirmation": "Opravdu chcete obnovit všechny vyhozené položky? Tuto akci nelze vrátit zpět!", + "assets_restore_confirmation": "Opravdu chcete obnovit všechny vyhozené položky? Tuto akci nelze vrátit zpět! Upozorňujeme, že tímto způsobem nelze obnovit žádné offline položky.", "assets_restored_count": "{count, plural, one {Obnovena # položka} few {Obnoveny # položky} other {Obnoveno # položek}}", "assets_trashed_count": "{count, plural, one {Vyhozena # položka} few {Vyhozeny # položky} other {Vyhozeno # položek}}", "assets_were_part_of_album_count": "{count, plural, one {Položka byla} other {Položky byly}} součástí alba", @@ -405,6 +431,7 @@ "birthdate_saved": "Datum narození úspěšně uloženo", "birthdate_set_description": "Datum narození se používá k výpočtu věku osoby v době pořízení fotografie.", "blurred_background": "Rozmazané pozadí", + "bugs_and_feature_requests": "Chyby a návrhy na funkce", "build": "Sestavení", "build_image": "Sestavení obrazu", "bulk_delete_duplicates_confirmation": "Opravdu chcete hromadně odstranit {count, plural, one {# duplicitní položku} few {# duplicitní položky} other {# duplicitních položek}}? Tím se zachová největší položka z každé skupiny a všechny ostatní duplicity se trvale odstraní. Tuto akci nelze vrátit zpět!", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Vymazat všechna nedávná vyhledávání", "clear_message": "Vyčistit zprávu", "clear_value": "Vyčistit hodnotu", + "clockwise": "Po směru hodinových ručiček", "close": "Zavřít", "collapse": "Sbalit", "collapse_all": "Sbalit vše", + "color": "Barva", "color_theme": "Barevný motiv", "comment_deleted": "Komentář odstraněn", "comment_options": "Možnosti komentáře", @@ -477,6 +506,8 @@ "create_new_person": "Vytvořit novou osobu", "create_new_person_hint": "Přiřadit vybrané položky nové osobě", "create_new_user": "Vytvořit nového uživatele", + "create_tag": "Vytvořit značku", + "create_tag_description": "Vytvoření nové značky. U vnořených značek zadejte celou cestu ke značce včetně dopředných lomítek.", "create_user": "Vytvořit uživatele", "created": "Vytvořeno", "current_device": "Současné zařízení", @@ -500,13 +531,17 @@ "delete_library": "Smazat knihovnu", "delete_link": "Smazat odkaz", "delete_shared_link": "Smazat sdílený odkaz", + "delete_tag": "Smazat značku", + "delete_tag_confirmation_prompt": "Opravdu chcete odstranit značku {tagName}?", "delete_user": "Odstranit uživatele", "deleted_shared_link": "Smazat sdílený odkaz", + "deletes_missing_assets": "Odstraní položky chybějící na disku", "description": "Popis", "details": "Podrobnosti", "direction": "Směr", "disabled": "Zakázáno", "disallow_edits": "Zakázat úpravy", + "discord": "Discord", "discover": "Objevit", "dismiss_all_errors": "Zrušit všechny chyby", "dismiss_error": "Zrušit chybu", @@ -515,8 +550,11 @@ "display_original_photos": "Zobrazit originální fotky", "display_original_photos_setting_description": "Preferovat zobrazení původních fotek při prohlížení položek namísto miniatur, pokud je originální položka kompatibilní s webem. To může mít za následek nižší rychlost zobrazení fotek.", "do_not_show_again": "Tuto zprávu již nezobrazovat", + "documentation": "Dokumentace", "done": "Hotovo", "download": "Stáhnout", + "download_include_embedded_motion_videos": "Vložená videa", + "download_include_embedded_motion_videos_description": "Zahrnout videa vložená do pohyblivých fotografií jako samostatný soubor", "download_settings": "Stahování", "download_settings_description": "Správa nastavení souvisejících se stahováním", "downloading": "Stahování", @@ -546,10 +584,15 @@ "edit_location": "Upravit polohu", "edit_name": "Upravit jméno", "edit_people": "Upravit lidi", + "edit_tag": "Upravit značku", "edit_title": "Upravit název", "edit_user": "Upravit uživatele", "edited": "Upraveno", "editor": "Editor", + "editor_close_without_save_prompt": "Změny nebudou uloženy", + "editor_close_without_save_title": "Zavřít editor?", + "editor_crop_tool_h2_aspect_ratios": "Poměr stran", + "editor_crop_tool_h2_rotation": "Otočení", "email": "E-mail", "empty": "Prázdné", "empty_album": "Prázdné album", @@ -590,7 +633,7 @@ "failed_to_load_assets": "Nepodařilo se načíst položky", "failed_to_load_people": "Chyba načítání osob", "failed_to_remove_product_key": "Nepodařilo se odebrat klíč produktu", - "failed_to_stack_assets": "Nepodařilo se poskládat položky", + "failed_to_stack_assets": "Nepodařilo se seskupit položky", "failed_to_unstack_assets": "Nepodařilo se rozložit položky", "import_path_already_exists": "Tato cesta importu již existuje.", "incorrect_email_or_password": "Nesprávný e-mail nebo heslo", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Nelze načíst počet komentářů", "unable_to_get_shared_link": "Nepodařilo se získat sdílený odkaz", "unable_to_hide_person": "Nelze skrýt osobu", + "unable_to_link_motion_video": "Nelze připojit pohyblivé video", "unable_to_link_oauth_account": "Nelze propojit OAuth účet", "unable_to_load_album": "Nelze načíst album", "unable_to_load_asset_activity": "Nelze načíst aktivitu položky", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Nelze odstranit API klíč", "unable_to_remove_assets_from_shared_link": "Nelze odstranit položky ze sdíleného odkazu", "unable_to_remove_comment": "Nelze odstranit komentář", + "unable_to_remove_deleted_assets": "Nelze odstranit offline soubory", "unable_to_remove_library": "Nelze odstranit knihovnu", - "unable_to_remove_offline_files": "Nelze odstranit offline soubory", "unable_to_remove_partner": "Nelze odebrat partnera", "unable_to_remove_reaction": "Nelze odstranit reakci", "unable_to_remove_user": "Nelze odebrat uživatele", @@ -679,6 +723,7 @@ "unable_to_submit_job": "Nelze odeslat úlohu", "unable_to_trash_asset": "Nelze vyhodit položku do koše", "unable_to_unlink_account": "Nelze zrušit propojení účtu", + "unable_to_unlink_motion_video": "Nelze odpojit pohyblivé video", "unable_to_update_album_cover": "Nelze aktualizovat obal alba", "unable_to_update_album_info": "Nelze aktualizovat informace o albu", "unable_to_update_library": "Nelze aktualizovat knihovnu", @@ -699,6 +744,7 @@ "expired": "Vypršela platnost", "expires_date": "Platnost končí {date}", "explore": "Prozkoumat", + "explorer": "Průzkumník", "export": "Export", "export_as_json": "Exportovat jako JSON", "extension": "Přípona", @@ -712,6 +758,8 @@ "feature": "Funkce", "feature_photo_updated": "Hlavní fotka aktualizována", "featurecollection": "Kolekce Funkcí", + "features": "Funkce", + "features_setting_description": "Správa funkcí aplikace", "file_name": "Název souboru", "file_name_or_extension": "Název nebo přípona souboru", "filename": "Filename", @@ -720,6 +768,8 @@ "filter_people": "Filtrovat lidi", "find_them_fast": "Najděte je rychle vyhledáním jejich jména", "fix_incorrect_match": "Opravit nesprávnou shodu", + "folders": "Složky", + "folders_feature_description": "Procházení zobrazení složek s fotografiemi a videi v souborovém systému", "force_re-scan_library_files": "Vynucené prohledání všech souborů knihovny", "forward": "Dopředu", "general": "Obecné", @@ -819,6 +869,7 @@ "license_trial_info_4": "Zvažte prosím zakoupení licence na podporu dalšího rozvoje služby", "light": "Světlý", "like_deleted": "Lajk smazán", + "link_motion_video": "Připojit pohyblivé video", "link_options": "Možnosti odkazu", "link_to_oauth": "Propojit s OAuth", "linked_oauth_account": "Propojený OAuth účet", @@ -837,6 +888,7 @@ "look": "Zobrazení", "loop_videos": "Videa ve smyčce", "loop_videos_description": "Povolit automatickou smyčku videa v prohlížeči.", + "main_branch_warning": "Používáte vývojovou verzi; důrazně doporučujeme používat verzi z vydání!", "make": "Výrobce", "manage_shared_links": "Spravovat sdílené odkazy", "manage_sharing_with_partners": "Správa sdílení s partnery", @@ -906,12 +958,14 @@ "notifications": "Oznámení", "notifications_setting_description": "Správa oznámení", "oauth": "OAuth", + "official_immich_resources": "Oficiální zdroje Immich", "offline": "Offline", "offline_paths": "Offline cesty", "offline_paths_description": "Tyto výsledky mohou být způsobeny ručním odstraněním souborů, které nejsou součástí externí knihovny.", "ok": "Ok", "oldest_first": "Nejstarší první", "onboarding": "Zahájení", + "onboarding_privacy_description": "Následující (volitelné) funkce jsou závislé na externích službách a lze je kdykoli zakázat v nastavení správy.", "onboarding_storage_template_description": "Pokud je tato funkce povolena, automaticky uspořádá soubory na základě uživatelem definované šablony. Vzhledem k problémům se stabilitou byla tato funkce ve výchozím nastavení vypnuta. Další informace naleznete v [dokumentaci].", "onboarding_theme_description": "Zvolte si barevné téma pro svou instanci. Můžete to později změnit v nastavení.", "onboarding_welcome_description": "Nastavíme vaši instanci pomocí několika běžných nastavení.", @@ -919,6 +973,7 @@ "online": "Online", "only_favorites": "Pouze oblíbené", "only_refreshes_modified_files": "Obnovuje pouze změněné soubory", + "open_in_map_view": "Otevřít v zobrazení mapy", "open_in_openstreetmap": "Otevřít v OpenStreetMap", "open_the_search_filters": "Otevřít vyhledávací filtry", "options": "Možnosti", @@ -953,6 +1008,7 @@ "pending": "Čekající", "people": "Lidé", "people_edits_count": "Upraveno {count, plural, one {# osoba} few {# osoby} other {# lidí}}", + "people_feature_description": "Procházení fotografií a videí seskupených podle osob", "people_sidebar_description": "Zobrazit sekci Lidé v postranním panelu", "perform_library_tasks": "", "permanent_deletion_warning": "Upozornění na trvalé smazání", @@ -985,6 +1041,7 @@ "previous_memory": "Předchozí vzpomínka", "previous_or_next_photo": "Předchozí nebo další fotka", "primary": "Primární", + "privacy": "Soukromí", "profile_image_of_user": "Profilový obrázek uživatele {user}", "profile_picture_set": "Profilový obrázek nastaven.", "public_album": "Veřejné album", @@ -1022,6 +1079,10 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Produktový klíč serveru spravuje správce", "range": "Rozsah", + "rating": "Hodnocení hvězdičkami", + "rating_clear": "Vyčistit hodnocení", + "rating_count": "{count, plural, one {# hvězdička} few {# hvězdičky} other {# hvězdček}}", + "rating_description": "Zobrazit EXIF hodnocení v informačním panelu", "raw": "Raw", "reaction_options": "Možnosti reakce", "read_changelog": "Přečtěte si seznam změn", @@ -1033,27 +1094,30 @@ "recent_searches": "Nedávná vyhledávání", "refresh": "Obnovit", "refresh_encoded_videos": "Obnovit kódovaná videa", + "refresh_faces": "Obnovit obličeje", "refresh_metadata": "Obnovit metadata", - "refresh_thumbnails": "Obnovit náhledy", + "refresh_thumbnails": "Obnovit miniatury", "refreshed": "Obnoveno", - "refreshes_every_file": "Obnoví každý soubor", + "refreshes_every_file": "Znovu načte všechny stávající a nové soubory", "refreshing_encoded_video": "Obnovování kódovaného videa", + "refreshing_faces": "Obnovování obličejů", "refreshing_metadata": "Obnovování metadat", - "regenerating_thumbnails": "Regenerace náhledů", + "regenerating_thumbnails": "Regenerace miniatur", "remove": "Odstranit", "remove_assets_album_confirmation": "Opravdu chcete z alba odstranit {count, plural, one {# položku} few {# položky} other {# položek}}?", "remove_assets_shared_link_confirmation": "Opravdu chcete ze sdíleného odkazu odstranit {count, plural, one {# položku} few {# položky} other {# položek}}?", "remove_assets_title": "Odstranit položky?", "remove_custom_date_range": "Odstranit vlastní rozsah datumů", + "remove_deleted_assets": "Odstranit offline soubory", "remove_from_album": "Odstranit z alba", "remove_from_favorites": "Odstranit z oblíbených", "remove_from_shared_link": "Odstranit ze sdíleného odkazu", - "remove_offline_files": "Odstranit offline soubory", "remove_user": "Odebrat uživatele", "removed_api_key": "Odstraněn API klíč: {name}", "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_tagged_assets": "Odstraněná značka z {count, plural, one {# položky} other {# položek}}", "rename": "Přejmenovat", "repair": "Opravy", "repair_no_results_message": "Zde se zobrazí neznámé a chybějící soubory", @@ -1085,6 +1149,7 @@ "say_something": "Řekněte něco", "scan_all_libraries": "Prohledat všechny knihovny", "scan_all_library_files": "Prohledání všech souborů knihovny", + "scan_library": "Prohledat", "scan_new_library_files": "Hledat nové soubory v knihovně", "scan_settings": "Nastavení prohledávání", "scanning_for_album": "Prohledávání alba...", @@ -1100,9 +1165,12 @@ "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_settings": "Hledat nastavení", "search_state": "Vyhledat stát...", + "search_tags": "Vyhledávat značky...", "search_timezone": "Vyhledat časové pásmo...", "search_type": "Typ vyhledávání", "search_your_photos": "Vyhledávejte svoje fotky", @@ -1126,8 +1194,8 @@ "send_message": "Odeslat zprávu", "send_welcome_email": "Poslat uvítací e-mail", "server": "Server", - "server_offline": "Server Offline", - "server_online": "Server Online", + "server_offline": "Server offline", + "server_online": "Server online", "server_stats": "Statistiky serveru", "server_version": "Verze serveru", "set": "Nastavit", @@ -1140,10 +1208,11 @@ "settings_saved": "Nastavení uloženo", "share": "Sdílet", "shared": "Sdílené", - "shared_by": "Sdílel", - "shared_by_user": "Sdíleno uživatelem {user}", + "shared_by": "Sdílel(a)", + "shared_by_user": "Sdílel(a) {user}", "shared_by_you": "Sdíleli jste", "shared_from_partner": "Fotky od {partner}", + "shared_link_options": "Možnosti sdíleného odkazu", "shared_links": "Sdílené odkazy", "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}", @@ -1152,6 +1221,7 @@ "sharing_sidebar_description": "Zobrazit sekci Sdílení v postranním panelu", "shift_to_permanent_delete": "stiskněte ⇧ pro trvalé odstranění položky", "show_album_options": "Zobrazit možnosti alba", + "show_albums": "Zobrazit alba", "show_all_people": "Zobrazit všechny lidi", "show_and_hide_people": "Zobrazit a skrýt osoby", "show_file_location": "Zobrazit umístění souboru", @@ -1166,13 +1236,18 @@ "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_slideshow_transition": "Zobrazit přechod prezentace", "show_supporter_badge": "Odznak podporovatele", "show_supporter_badge_description": "Zobrazit odznak podporovatele", "shuffle": "Náhodný výběr", + "sidebar": "Postranní panel", + "sidebar_display_description": "Zobrazení odkazu na zobrazení v postranním panelu", "sign_out": "Odhlásit se", "sign_up": "Zaregistrovat se", "size": "Velikost", "skip_to_content": "Přejít na obsah", + "skip_to_folders": "Přeskočit na složky", + "skip_to_tags": "Přeskočit na značky", "slideshow": "Prezentace", "slideshow_settings": "Nastavení prezentace", "sort_albums_by": "Seřadit alba podle...", @@ -1183,8 +1258,10 @@ "sort_recent": "Nejnovější fotka", "sort_title": "Název", "source": "Zdroj", - "stack": "Zásobník", - "stack_selected_photos": "Zásobník vybraných fotografií", + "stack": "Seskupit", + "stack_duplicates": "Seskupit duplicity", + "stack_select_one_photo": "Vyberte jednu hlavní fotografii pro seskupení", + "stack_selected_photos": "Seskupení vybraných fotografií", "stacked_assets_count": "{count, plural, one {Seskupena # položka} few {Seskupeny # položky} other {Seskupeno # položek}}", "stacktrace": "Výpis zásobníku", "start": "Start", @@ -1201,22 +1278,36 @@ "submit": "Odeslat", "suggestions": "Návrhy", "sunrise_on_the_beach": "Východ slunce na pláži", + "support": "Podpora", + "support_and_feedback": "Podpora a zpětná vazba", + "support_third_party_description": "Vaše Immich instalace byla připravena třetí stranou. Problémy, které se u vás vyskytly, mohou být způsobeny tímto balíčkem, proto se na ně obraťte v první řadě pomocí níže uvedených odkazů.", "swap_merge_direction": "Obrátit směr sloučení", "sync": "Synchronizovat", + "tag": "Značka", + "tag_assets": "Přiřadit značku", + "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_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", "template": "Šablona", "theme": "Motiv", "theme_selection": "Výběr motivu", "theme_selection_description": "Automatické nastavení světlého nebo tmavého motivu podle systémových preferencí prohlížeče", "they_will_be_merged_together": "Budou sloučeny dohromady", + "third_party_resources": "Zdroje třetích stran", "time_based_memories": "Časové vzpomínky", "timezone": "Časové pásmo", "to_archive": "Archivovat", "to_change_password": "Změnit heslo", "to_favorite": "Oblíbit", "to_login": "Přihlásit", + "to_parent": "Přejít k rodiči", + "to_root": "Přejít ke kořenu", "to_trash": "Vyhodit", "toggle_settings": "Přepnout nastavení", - "toggle_theme": "Přepnout motiv", + "toggle_theme": "Přepnout tmavý motiv", "toggle_visibility": "Přepnout viditelnost", "total_usage": "Celkové využití", "trash": "Koš", @@ -1235,14 +1326,16 @@ "unknown_album": "Neznámé album", "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", "unnamed_album": "Nepojmenované album", + "unnamed_album_delete_confirmation": "Opravdu chcete toto album smazat?", "unnamed_share": "Nejmenované sdílení", "unsaved_change": "Neuložená změna", "unselect_all": "Zrušit výběr všech", "unselect_all_duplicates": "Zrušit výběr všech duplicit", - "unstack": "Zrušit zásobník", + "unstack": "Zrušit seskupení", "unstacked_assets_count": "{count, plural, one {Rozložena # položka} few {Rozloženy # položky} other {Rozloženo # položek}}", "untracked_files": "Nesledované soubory", "untracked_files_decription": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", @@ -1258,7 +1351,7 @@ "upload_status_uploaded": "Nahráno", "upload_success": "Nahrání proběhlo úspěšně, obnovením stránky se zobrazí nově nahrané položky.", "url": "URL", - "usage": "Použití", + "usage": "Využití", "use_custom_date_range": "Použít vlastní rozsah dat", "user": "Uživatel", "user_id": "ID uživatele", @@ -1277,6 +1370,8 @@ "version": "Verze", "version_announcement_closing": "Váš přítel Alex", "version_announcement_message": "Ahoj příteli, je tu nová verze aplikace, věnuj prosím čas přečtení poznámek k vydání a zajisti si, aby docker-compose.yml a nastavení .env bylo aktuální, a aby nedošlo k chybné konfiguraci, zejména pokud používáš WatchTower nebo jiný mechanismus, který se stará o automatickou aktualizaci aplikace.", + "version_history": "Historie verzí", + "version_history_item": "Nainstalováno {version} dne {date}", "video": "Video", "video_hover_setting": "Přehrávat miniaturu videa po najetí myší", "video_hover_setting_description": "Přehrát miniaturu videa při najetí myší na položku. I když je přehrávání vypnuto, lze jej spustit najetím na ikonu přehrávání.", @@ -1286,10 +1381,11 @@ "view_album": "Zobrazit album", "view_all": "Zobrazit vše", "view_all_users": "Zobrazit všechny uživatele", + "view_in_timeline": "Zobrazit na časové ose", "view_links": "Zobrazit odkazy", "view_next_asset": "Zobrazit další položku", "view_previous_asset": "Zobrazit předchozí položku", - "view_stack": "Zobrazit zásobník", + "view_stack": "Zobrazit seskupení", "viewer": "Prohlížeč", "visibility_changed": "Viditelnost změněna u {count, plural, one {# osoby} few {# osob} other {# lidí}}", "waiting": "Čekající", diff --git a/i18n/cv.json b/i18n/cv.json new file mode 100644 index 0000000000..8f0581053e --- /dev/null +++ b/i18n/cv.json @@ -0,0 +1,49 @@ +{ + "about": "Ҫинчен", + "account": "Шута ҫырни", + "account_settings": "Шута ҫырни ӗнерленӳ", + "acknowledge": "Çирӗплет", + "action": "Ӗçлени", + "actions": "Ӗҫсем", + "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": "Пӗрлехи альбома хуш", + "added_to_archive": "Архива хушнӑ", + "added_to_favorites": "Суйласа илнине хушнӑ", + "added_to_favorites_count": "Суйласа илнине {count, number} хушнӑ", + "admin": { + "asset_offline_description": "Библиотекӑн ҫак тулаш файлне дискра урӑх тупайман, карҫинккана куҫарнӑ. Енчен те файла вулавӑш ӑшне куҫарнӑ пулсан, тивӗҫлӗ ҫӗнӗ ресурс тупас тесен хӑвӑрӑн вӑхӑтлӑх шкалӑна тӗрӗслӗр. Ҫак файла ҫӗнӗрен чӗртес тесен файл патне каймалли ҫула Immich валли аяларах ҫитернине курса ӗненӗр, библиотекӑна сканерланине пурнӑҫлӑр.", + "authentication_settings_disable_all": "Эсир кӗмелли пур меслетсене те чарса лартасшӑн тесе шутлатӑр-и? Кӗмелли шӑтӑка пӗтӗмпех уҫаҫҫӗ.", + "background_task_job": "Курăнман ӗҫсем", + "check_all": "Пурне те тӗрӗслӗр", + "cleared_jobs": "Ӗҫсене тасатнӑ:{job}", + "confirm_email_below": "Ҫирӗплетес тесен, аяларах «{email}» кӗртӗр", + "confirm_reprocess_all_faces": "Пӗтӗм сӑнӗсене тепӗр хут палӑртас килет тесе шанатӑр-и? Ҫавӑн пекех ятсене пур ҫынран та хуратӗҫ.", + "create_job": "Ӗҫе ту", + "disable_login": "Кӗме чарӑр", + "duplicate_detection_job_description": "Пӗр пек ӳкерчӗксене тупма машинӑллӑ вӗренӗве ӗҫлеттерӗр. Ӑслӑ шыравпа усӑ кураҫҫӗ", + "face_detection": "Пит-куҫа тупасси", + "force_delete_user_warning": "ПУЛТАРУЛӐХ: Ку усӑ куракана тата мӗнпур ресурса ҫийӗнчех кӑларса пӑрахасси патне илсе ҫитерӗ. Кӑна пӑрахӑҫлама май ҫук, файлсене те юсаса пӗтереймеҫҫӗ.", + "image_format": "Тулашлăх", + "image_preview_description": "Вӑтам пысӑкӑш ӳкерчӗк, уйрӑм метаданнӑйсем, пӗр объекта пӑхнӑ чухне тата машинӑллӑ вӗренӳре усӑ кураҫҫӗ", + "image_preview_quality_description": "1-100 таран малтанхи пахалӑх. Ҫӳллӗреххи лайӑхрах, анчах та пысӑкрах файлсем туса кӑларать тата приложенисен хуравлӑхне чакарма пултарать. Пӗчӗк хак лартни машинӑллӑ вӗренӳ пахалӑхне витӗм кӳме пултарать.", + "image_preview_title": "Малтанлӑха пӑхмалли ӗнерлевсем", + "image_quality": "Пахалӑх", + "image_resolution": "Виҫе" + } +} diff --git a/web/src/lib/i18n/da.json b/i18n/da.json similarity index 82% rename from web/src/lib/i18n/da.json rename to i18n/da.json index e7fb7bbf68..9a4101b023 100644 --- a/web/src/lib/i18n/da.json +++ b/i18n/da.json @@ -5,9 +5,9 @@ "acknowledge": "Anerkend", "action": "Handling", "actions": "Handlinger", - "active": "Aktiv", + "active": "Aktive", "activity": "Aktivitet", - "activity_changed": "Aktivitet er {enabled, select, true {enabled} other {disabled}}", + "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", @@ -25,31 +25,31 @@ "add_to_shared_album": "Tilføj til delt album", "added_to_archive": "Tilføjet til arkiv", "added_to_favorites": "Tilføjet til favoritter", - "added_to_favorites_count": "Tilføjet {count} til favoritter", + "added_to_favorites_count": "Tilføjet {count, number} til favoritter", "admin": { "add_exclusion_pattern_description": "Tilføj udelukkelsesmønstre. Globbing ved hjælp af *, ** og ? understøttes. For at ignorere alle filer i enhver mappe med navnet \"Raw\", brug \"**/Raw/**\". For at ignorere alle filer, der slutter på \".tif\", brug \"**/*.tif\". For at ignorere en absolut sti, brug \"/sti/til/ignoreret/**\".", "authentication_settings": "Godkendelsesindstillinger", "authentication_settings_description": "Administrer adgangskode, OAuth og andre godkendelsesindstillinger", - "authentication_settings_disable_all": "Er du sikker på at du vil deaktivere alle login muligheder? Login vil blive helt deaktiveret.", + "authentication_settings_disable_all": "Er du sikker på at du vil deaktivere alle loginmuligheder? Login vil blive helt deaktiveret.", "authentication_settings_reenable": "Brug en server-kommando for at genaktivere.", "background_task_job": "Baggrundsopgaver", "check_all": "Tjek Alle", "cleared_jobs": "Ryddet jobs til: {job}", "config_set_by_file": "konfigurationen er i øjeblikket indstillet af en konfigurations fil", "confirm_delete_library": "Er du sikker på, at du vil slette {library} bibliotek?", - "confirm_delete_library_assets": "Er du sikker på, at du vil slette dette bibliotek? Dette vil slette alle {count} indeholdte aktiver fra Immich og kan ikke gøres om. Filerne forbliver på disken.", + "confirm_delete_library_assets": "Er du sikker på, at du vil slette dette bibliotek? Dette vil slette {count, plural, one {# indeholdt mediefil} other {alle # indeholdte mediefiler}} fra Immich og kan ikke gøres om. Filerne forbliver på disken.", "confirm_email_below": "For at bekræfte, skriv \"{email}\" herunder", "confirm_reprocess_all_faces": "Er du sikker på, at du vil genbehandle alle ansigter? Dette vil også rydde navngivne personer.", "confirm_user_password_reset": "Er du sikker på, at du vil nulstille {user}s adgangskode?", "crontab_guru": "Crontab Guru", "disable_login": "Deaktiver login", "disabled": "", - "duplicate_detection_job_description": "Kør maskinlæring på aktiver for at opdage lignende billeder. Er afhængig af Smart Søgning", + "duplicate_detection_job_description": "Kør maskinlæring på mediefiler for at opdage lignende billeder. Er afhængig af Smart Søgning", "exclusion_pattern_description": "Ekskluderingsmønstre lader dig ignorere filer og mapper, når du scanner dit bibliotek. Dette er nyttigt, hvis du har mapper, der indeholder filer, du ikke vil importere, såsom RAW-filer.", "external_library_created_at": "Eksternt bibliotek (oprettet {date})", "external_library_management": "Ekstern biblioteksstyring", "face_detection": "Ansigtsopdagelse", - "face_detection_description": "Genkend ansigterne i aktiver via maskinlæring. For videoer er det kun miniaturebilledet som tages hensyn til. \"Alle\" (gen-)behandler alle aktiver. \"Mangler\" sætter aktiver i kø, som ikke er blevet behandlet endnu. Opdagede ansigter vil blive sat i kø til Ansigtsgenkendelse efter Ansigtsopdagelse er færdig, hvilket grupperer dem til eksisterende eller nye personer.", + "face_detection_description": "Genkend ansigterne i mediefiler via maskinlæring. For videoer er det kun miniaturebilledet som tages hensyn til. \"Alle\" (gen-)behandler alle mediefiler. \"Mangler\" sætter mediefiler i kø, som ikke er blevet behandlet endnu. Opdagede ansigter vil blive sat i kø til Ansigtsgenkendelse efter Ansigtsopdagelse er færdig, hvilket grupperer dem til eksisterende eller nye personer.", "facial_recognition_job_description": "Grupper opdagede ansigter i personer. Dette trin kører efter Ansigtsopdagelse er færdig. \"Alle\" (gen-)klumper alle ansigter sammen. \"Mangler\" sætter ansigter i kø, som ikke har en person tildelt.", "failed_job_command": "Kommando {command} mislykkedes for job: {job}", "force_delete_user_warning": "ADVARSEL: Dette vil øjeblikkeligt fjerne brugeren og alle Billeder/Videoer. Dette kan ikke fortrydes, og filerne kan ikke gendannes.", @@ -74,8 +74,8 @@ "job_settings": "Jobindstillinger", "job_settings_description": "Administrér samtidige opgaver", "job_status": "Opgave Status", - "jobs_delayed": "{jobCount} forsinket", - "jobs_failed": "{jobCount} fejlede", + "jobs_delayed": "{jobCount, plural, one {# forsinket} other {# forsinkede}}", + "jobs_failed": "{jobCount, plural, one {# fejlet} other {# fejlede}}", "library_created": "Skabte bibliotek: {library}", "library_cron_expression": "Cron-udtryk", "library_cron_expression_description": "Sæt skannings interval ved at bruge cron formatet. For mere information se dokumentation her Crontab Guru", @@ -95,9 +95,10 @@ "logging_level_description": "Når slået til, hvilket logniveau, der skal bruges.", "logging_settings": "Logning", "machine_learning_clip_model": "CLIP-model", + "machine_learning_clip_model_description": "Navnet på CLIP-modellen på listen her. Bemærk at du skal genkøre \"Smart Søgning\"-jobbet for alle billeder, hvis du skifter model.", "machine_learning_duplicate_detection": "Dubletdetektion", "machine_learning_duplicate_detection_enabled": "Aktiver duplikatdetektion", - "machine_learning_duplicate_detection_enabled_description": "Når slået fra, vil nøjagtigt identiske aktiver blive de-duplikeret.", + "machine_learning_duplicate_detection_enabled_description": "Når slået fra, vil nøjagtigt identiske mediefiler blive de-duplikerede.", "machine_learning_duplicate_detection_setting_description": "Brug CLIP-indlejringer til at finde sandsynlige duplikater", "machine_learning_enabled": "Aktivér maskinlæring", "machine_learning_enabled_description": "Hvis deaktiveret, vil alle ML-funktioner blive deaktiveret uanset nedenstående indstillinger.", @@ -125,25 +126,33 @@ "manage_concurrency": "Administrer antallet af samtidige opgaver", "manage_log_settings": "Administrer logindstillinger", "map_dark_style": "Mørk tema", - "map_enable_description": "Aktiver kort funktioner", + "map_enable_description": "Aktivér kortfunktioner", + "map_gps_settings": "Kort- og GPS-indstillinger", + "map_gps_settings_description": "Håndter indstillinger for Kort og GPS (Omvendt Geokodning)", + "map_implications": "Kortfunktionen afhænger af en ekstern tile-service (tiles.immich.cloud)", "map_light_style": "Lyst tema", + "map_manage_reverse_geocoding_settings": "Håndtér indstillinger for Omvendt Geokoding", "map_reverse_geocoding": "Omvendt geokodning", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokodning", "map_reverse_geocoding_settings": "Omvendt geokodningsindstillinger", - "map_settings": "Kort og GPS-indstillinger", + "map_settings": "Kort", "map_settings_description": "Administrer kortindstillinger", "map_style_description": "URL til en style.json for et korttema", "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", + "metadata_faces_import_setting_description": "Importerer ansigter fra billed EXIF-data og forbandt filer", + "metadata_settings": "Metadatainstillinger", + "metadata_settings_description": "Håndtér metadataindstillinger", "migration_job": "Migrering", "migration_job_description": "Migrér miniaturebilleder for aktiver og ansigter til den seneste mappestruktur", "no_paths_added": "Ingen stier tilføjet", "no_pattern_added": "Intet mønster tilføjet", - "note_apply_storage_label_previous_assets": "Bemærk: For at anvende Lagringsmærkatet på tidligere uploadede aktiver, kør", + "note_apply_storage_label_previous_assets": "Bemærk: For at anvende Lagringsmærkatet på tidligere uploadede mediefiler, kør", "note_cannot_be_changed_later": "BEMÆRK: Dette kan ikke ændres senere!", "note_unlimited_quota": "Bemærk: Indsæt 0 for uendelig kvote", "notification_email_from_address": "Fra adressse", - "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", + "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", "notification_email_host_description": "Host af emailserver (fx smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorér certifikatfejl", "notification_email_ignore_certificate_errors_description": "Ignorér TLS-certifikatgodkendelsesfejl (ikke anbefalet)", @@ -151,6 +160,7 @@ "notification_email_port_description": "Emailserverens port (fx 25, 465 eller 587)", "notification_email_sent_test_email_button": "Send test-email og gem", "notification_email_setting_description": "Indstillinger for sending af emailnotifikationer", + "notification_email_test_email": "Send test-email", "notification_email_test_email_failed": "Fejl ved afsendelse af test-email, tjek dine værdier", "notification_email_test_email_sent": "En test-email er blevet sendt til {email}. Venligst tjek din inbox.", "notification_email_username_description": "Brugernavn til brug ved autentificering med e-mailserveren", @@ -168,10 +178,13 @@ "oauth_issuer_url": "Udsteder-URL", "oauth_mobile_redirect_uri": "Mobilomdiregerings-URL", "oauth_mobile_redirect_uri_override": "Tilsidesættelse af mobil omdiregerings-URL", - "oauth_mobile_redirect_uri_override_description": "Slå til når \"app.immich:/\" er en ugyldig omdiregerings-URL.", + "oauth_mobile_redirect_uri_override_description": "Aktiver, når OAuth-udbyderen ikke tillader en mobil URI, som '{callback}'", + "oauth_profile_signing_algorithm": "Log-ind-algoritme", + "oauth_profile_signing_algorithm_description": "Algoritme til signering af brugerprofilen.", "oauth_scope": "Omfang", "oauth_settings": "OAuth", "oauth_settings_description": "Administrer OAuth login-indstillinger", + "oauth_settings_more_details": "Læs flere detaljer om funktionen i dokumentationen.", "oauth_signing_algorithm": "Signeringsalgoritme", "oauth_storage_label_claim": "Lagringsmærkat fordring", "oauth_storage_label_claim_description": "Sæt automatisk brugerens lagringsmærkat til denne fordrings værdi.", @@ -187,7 +200,9 @@ "paths_validated_successfully": "Alle stier valideret med succes", "quota_size_gib": "Kvotestørrelse (GiB)", "refreshing_all_libraries": "Opdaterer alle biblioteker", - "removing_offline_files": "Fjerner offline-filer", + "registration": "Administratorregistrering", + "registration_description": "Da du er den første bruger i systemet, får du tildelt rollen som administrator og ansvar for administration og oprettelsen af nye brugere.", + "removing_deleted_files": "Fjerner offline-filer", "repair_all": "Reparér alle", "repair_matched_items": "Har parret {count, plural, one {# element} other {# elementer}}", "repaired_items": "Reparerede {count, plural, one {# element} other {# elementer}}", @@ -206,14 +221,22 @@ "sidecar_job": "Medfølgende metadata", "sidecar_job_description": "Opdag eller synkroniser medfølgende metadata fra filsystemet", "slideshow_duration_description": "Antal sekunder at vise hvert billede", - "smart_search_job_description": "Kør maskinlæring på aktiver for at understøtte smart søgning", + "smart_search_job_description": "Kør maskinlæring på mediefiler for at understøtte smart søgning", + "storage_template_date_time_description": "Mediefilens oprettelsesregistrering er grundlag for dato-tid-informationen", + "storage_template_date_time_sample": "Eksempel tid {date}", "storage_template_enable_description": "Slå lagringsskabelonredskab til", - "storage_template_hash_verification_enabled": "Hash-verifikation slog fejl", + "storage_template_hash_verification_enabled": "Hash-verifikation slået til", "storage_template_hash_verification_enabled_description": "Slår hash-verifikation til, slå ikke dette fra med mindre du er sikker på dets konsekvenser", "storage_template_migration": "Lagringsskabelonmigration", - "storage_template_migration_job": "Lagringsmigrationsopgave", + "storage_template_migration_description": "Anvend den nuværende {template} på tidligere uploadede mediefiler", + "storage_template_migration_info": "Skabelonændringer vil kun gælde for nye mediefiler. For at anvende skabelonen retroaktivt på tidligere uploadede mediefiler skal du køre {job}.", + "storage_template_migration_job": "Lager Skabelon Migrationsjob", + "storage_template_more_details": "For flere detaljer om denne funktion, referer til Lager Skabelonen og dens implikationer", + "storage_template_onboarding_description": "Når denne funktion er aktiveret, vil den automatisk organisere filer baseret på en brugerdefineret skabelon. På grund af stabilitetsproblemer er funktionen som standard slået fra. For mere information, se dokumentation.", + "storage_template_path_length": "Anslået sti-længde begrænsning {length, number}/{limit, number}", "storage_template_settings": "Lagringsskabelon", - "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for det uploadede aktiv", + "storage_template_settings_description": "Administrer mappestrukturen og filnavnet for den uploadede mediefil", + "storage_template_user_label": "{label} er brugerens Lagringsmærkat", "system_settings": "Systemindstillinger", "theme_custom_css_settings": "Brugerdefineret CSS", "theme_custom_css_settings_description": "Cascading Style Sheets tillader at give Immich et brugerdefineret look.", @@ -221,22 +244,25 @@ "theme_settings_description": "Administrér brugertilpasningen af Immich's webinterface", "these_files_matched_by_checksum": "Disse filer er blevet matchet med deres checksummer", "thumbnail_generation_job": "Generér miniaturebilleder", - "thumbnail_generation_job_description": "Generér store, små og slørede miniaturebilleder for hvert aktiv, såvel som miniaturebilleder for hver person", + "thumbnail_generation_job_description": "Generér store, små og slørede miniaturebilleder for hver mediefil, såvel som miniaturebilleder for hver person", "transcode_policy_description": "", "transcoding_acceleration_api": "Accelerations-API", "transcoding_acceleration_api_description": "API'en som interagerer med din enhed for at accelerere transkodning. Denne er indstilling er \"i bedste fald\": Den vil falde tilbage til software-transkodning ved svigt. VP9 virker måske, måske ikke, afhængigt af dit hardware.", "transcoding_acceleration_nvenc": "NVENC (kræver NVIDIA GPU)", - "transcoding_acceleration_qsv": "Hurtigsynkronisering (kræver 7. generation Intel CPU eller senere)", + "transcoding_acceleration_qsv": "Quick Sync (kræver 7. generation Intel CPU eller senere)", "transcoding_acceleration_rkmpp": "RKMPP (kun på Rockchip SOC'er)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Accepterede lyd-codecs", "transcoding_accepted_audio_codecs_description": "Vælg hvilke lyd-codecs der ikke behøver at blive transkodet. Bruges kun ved bestemte transkodningspolitikker.", + "transcoding_accepted_containers": "Accepterede containere", + "transcoding_accepted_containers_description": "Vælg hvilke containerformater der ikke skal remuxes til MP4. Bruges kun for visse transkodningspolitiker.", "transcoding_accepted_video_codecs": "Accepterede video-codecs", "transcoding_accepted_video_codecs_description": "Vælg hvilke video-codec der ikke behøver at bliver transkodet. Bruges kun ved bestemte transkodningspolitikker.", "transcoding_advanced_options_description": "Indstillinger de fleste brugere ikke behøver at ændre på", "transcoding_audio_codec": "Lyd-codec", "transcoding_audio_codec_description": "Opus er den indstillingen med højest kvalitet, men har mindre kompatibilitet med gamle enheder og software.", "transcoding_bitrate_description": "Videoer med højere end maksimumbitrate eller ikke i et godkendt format", + "transcoding_codecs_learn_more": "For at lære mere om den terminologi der bruges her, henvises til FFmpeg dokumentationen for H.264 codec, HEVC codec og VP9 codec.", "transcoding_constant_quality_mode": "Konstant kvalitetstilstand", "transcoding_constant_quality_mode_description": "ICQ er bedre end CQP, men nogle hardwareaccelerationsenheder understøtter ikke denne tilstand. At slå denne indstilling til vil foretrække den specificerede tilstand når kvalitetsbaseret indkodning bruges. Ignoreret at NVENC, da det ikke understøtter ICQ.", "transcoding_constant_rate_factor": "Konstant ratefaktor (-crf)", @@ -245,7 +271,7 @@ "transcoding_hardware_acceleration": "Hardwareacceleration", "transcoding_hardware_acceleration_description": "Eksperimentel; meget hurtigere, men vil have lavere kvalitet ved samme bitrate", "transcoding_hardware_decoding": "Hardware-afkodning", - "transcoding_hardware_decoding_setting_description": "Gælder kun NVENC og RKMPP. Slår ende-til-ende acceleration til i stedet for kun at accelerere indkodning. Virker måske ikke på alle videoer.", + "transcoding_hardware_decoding_setting_description": "Gælder kun NVENC, QSV og RKMPP. Slår ende-til-ende acceleration til i stedet for kun at accelerere indkodning. Virker måske ikke på alle videoer.", "transcoding_hevc_codec": "HEVC-codec", "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.", @@ -286,15 +312,21 @@ "trash_settings_description": "Administrér skraldeindstillinger", "untracked_files": "Utrackede filer", "untracked_files_description": "Applikationen holder ikke styr på disse filer. De kan være resultatet af mislykkede flytninger, afbrudte uploads eller være efterladt på grund af en fejl", + "user_delete_delay": "{user}'s konto og mediefiler vil blive planlagt til permanent sletning om {delay, plural, one {# dag} other {# dage}}.", "user_delete_delay_settings": "Slet forsinkelse", - "user_delete_delay_settings_description": "Antal dage efter fjernelse for permanent at slette en brugers konto og aktiver. Opgaven for sletning af brugere kører ved midnat for at tjekke efter brugere, der er klar til sletning. Ændringer i denne indstilling vil blive evalueret ved næste udførelse.", + "user_delete_delay_settings_description": "Antal dage efter fjernelse for permanent at slette en brugers konto og mediefiler. Opgaven for sletning af brugere kører ved midnat for at tjekke efter brugere, der er klar til sletning. Ændringer i denne indstilling vil blive evalueret ved næste udførelse.", + "user_delete_immediately": "{user}'s konto og aktiver vil blive sat i kø til permanent sletning med det samme.", + "user_delete_immediately_checkbox": "Sæt bruger og aktiver i kø til øjeblikkelig sletning", "user_management": "Brugeradministration", "user_password_has_been_reset": "Brugerens adgangskode er blevet nulstillet:", "user_password_reset_description": "Venligst oplys brugeren om den midlertidige adgangskode og informér dem, at de vil være nødt til at ændre adgangskoden ved næste login.", + "user_restore_description": "{user}'s konto vil blive gendannet.", + "user_restore_scheduled_removal": "Gendan bruger - planlagt fjernelse den {date, date, long}", "user_settings": "Brugerindstillinger", "user_settings_description": "Administrér brugerindstillinger", "user_successfully_removed": "Bruger {email} er blevet fjernet med succes.", - "version_check_enabled_description": "Aktiver periodiske forespørgsler til GitHub for at tjekke efter nye versioner", + "version_check_enabled_description": "Aktivér versionstjek", + "version_check_implications": "Funktionen til versionstjek er afhængig af periodisk kommunikation med github.com", "version_check_settings": "Versiontjek", "version_check_settings_description": "Aktiver/deaktier notifikation for den nye version", "video_conversion_job": "Transkod videoer", @@ -304,62 +336,94 @@ "admin_password": "Administratoradgangskode", "administration": "Administration", "advanced": "Avanceret", + "age_months": "Alder {months, plural, one {# month} other {# months}}", + "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", + "album_delete_confirmation": "Er du sikker på at du vil slette albummet {album}?", + "album_delete_confirmation_description": "Hvis dette album er delt, vil andre brugere ikke længere kunne få adgang til det.", "album_info_updated": "Albuminfo opdateret", + "album_leave": "Forlad albummet?", + "album_leave_confirmation": "Er du sikker på at du vil forlade {album}?", "album_name": "Albumnavn", "album_options": "Albumindstillinger", + "album_remove_user": "Fjern bruger?", + "album_remove_user_confirmation": "Er du sikker på at du vil fjerne {user}?", + "album_share_no_users": "Det ser ud til at du har delt denne album med alle brugere, eller du har ikke nogen brugere til at dele med.", "album_updated": "Album opdateret", - "album_updated_setting_description": "Modtag en emailnotifikation når et delt album har nye aktiver", + "album_updated_setting_description": "Modtag en emailnotifikation når et delt album får nye mediefiler", + "album_user_left": "Forlod {album}", + "album_user_removed": "Fjernede {user}", "albums": "Albummer", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albummer}}", "all": "Alt", + "all_albums": "Alle albummer", "all_people": "Alle personer", + "all_videos": "Alle videoer", "allow_dark_mode": "Tillad mørk tilstand", "allow_edits": "Tillad redigeringer", + "allow_public_user_to_download": "Tillad offentlige brugere til at hente", + "allow_public_user_to_upload": "Tillad offentlige brugere til at uploade", + "anti_clockwise": "Mod uret", "api_key": "API-nøgle", + "api_key_description": "Denne værdi vises kun én gang. Venligst kopiér den før du lukker vinduet.", + "api_key_empty": "Din API-nøgle-navn burde ikke være tom", "api_keys": "API-nøgler", "app_settings": "Appindstillinger", "appears_in": "Optræder i", "archive": "Arkiv", "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": "Arkiveret", - "asset_offline": "Aktiv offline", + "asset_offline": "Mediefil offline", "assets": "elementer", "authorized_devices": "Tilladte enheder", "back": "Tilbage", "backward": "Baglæns", "blurred_background": "Sløret baggrund", + "bugs_and_feature_requests": "Fejl & forbedringsønsker", + "build": "Byg", + "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.", "camera": "Kamera", "camera_brand": "Kameramærke", "camera_model": "Kameramodel", - "cancel": "Annuller", + "cancel": "Annullér", "cancel_search": "Annullér søgning", "cannot_merge_people": "Kan ikke sammenflette personer", + "cannot_undo_this_action": "Du kan ikke fortryde denne handling!", "cannot_update_the_description": "Kan ikke opdatere beskrivelsen", "cant_apply_changes": "Kan ikke anvende ændringer", "cant_get_faces": "Kan ikke hente ansigter", "cant_search_people": "Kan ikke søge i personer", "cant_search_places": "Kan ikke søge i steder", "change_date": "Ændr dato", - "change_expiration_time": "Ændrer udløbstidspunkt", + "change_expiration_time": "Ændr udløbstidspunkt", "change_location": "Ændr sted", "change_name": "Ændr navn", - "change_name_successfully": "Navn ændret med succes", + "change_name_successfully": "Navn er ændret", "change_password": "Skift kodeord", - "change_your_password": "Skift din adgangskode", - "changed_visibility_successfully": "Ændrede synlighed med succes", - "check_all": "Tjek alle", - "check_logs": "Tjek logs", - "choose_matching_people_to_merge": "Vælg personer der matcher til sammenfletning", + "change_password_description": "Dette er enten første gang du tilmelder dig, eller en ændring af kodeordet blev bestilt. Indtast dit nye kodeord herunder.", + "change_your_password": "Skift dit kodeord", + "changed_visibility_successfully": "Synlighed blev ændret", + "check_all": "Markér alle", + "check_logs": "Tjek logfiler", + "choose_matching_people_to_merge": "Vælg matchende personer til sammenfletning", "city": "By", "clear": "Ryd", "clear_all": "Ryd alle", + "clear_all_recent_searches": "Ryd alle seneste søgninger", "clear_message": "Ryd bedsked", "clear_value": "Ryd værdi", + "clockwise": "Med uret", "close": "Luk", - "collapse_all": "Kollaps alle", + "collapse": "Klap sammen", + "collapse_all": "Klap alle sammen", + "color": "Farve", "color_theme": "Farvetema", "comment_options": "Kommentarindstillinger", "comments_are_disabled": "Kommentarer er slået fra", @@ -385,8 +449,9 @@ "create": "Opret", "create_album": "Opret album", "create_library": "Opret bibliotek", - "create_link": "Oprat link", + "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_new_person": "Opret ny person", "create_new_user": "Opret ny bruger", "create_user": "Opret bruger", @@ -422,9 +487,11 @@ "display_options": "Display-indstillinger", "display_order": "Display-rækkefølge", "display_original_photos": "Vis originale billeder", - "display_original_photos_setting_description": "Foretræk at vise det originale billede når et aktiv anskues fremfor miniaturebillederne når det originale aktiv er web-kompatibelt. Dette kan munde ud i langsommere billedvisningshastigheder.", + "display_original_photos_setting_description": "Foretræk at vise det originale billede frem for miniaturebilleder når den originale fil er web-kompatibelt. Dette kan gøre billedvisning langsommere.", "done": "Færdig", "download": "Hent", + "download_settings": "Download", + "download_settings_description": "Administrer indstillinger relateret til mediefil-downloads", "downloading": "Downloader", "duplicates": "Duplikater", "duration": "Varighed", @@ -486,7 +553,7 @@ "unable_to_create_library": "Ikke i stand til at oprette bibliotek", "unable_to_create_user": "Ikke i stand til at oprette bruger", "unable_to_delete_album": "Ikke i stand til at slette album", - "unable_to_delete_asset": "Ikke i stand til slette aktiv", + "unable_to_delete_asset": "Kan ikke slette mediefil", "unable_to_delete_exclusion_pattern": "Kunne ikke slette udelukkelsesmønster", "unable_to_delete_import_path": "Kunne ikke slette importsti", "unable_to_delete_shared_link": "Kunne ikke slette delt link", @@ -494,12 +561,12 @@ "unable_to_edit_exclusion_pattern": "Kunne ikke redigere udelukkelsesmønster", "unable_to_edit_import_path": "Kunne ikke redigere importsti", "unable_to_empty_trash": "Ikke i stand til at tømme skraldespand", - "unable_to_enter_fullscreen": "Ikke i stand til aktivere fuldskærmstilstand", - "unable_to_exit_fullscreen": "Ikke i stand til deaktivere fuldskærmstilstand", + "unable_to_enter_fullscreen": "Kan ikke aktivere fuldskærmstilstand", + "unable_to_exit_fullscreen": "Kan ikke forlade fuldskærmstilstand", "unable_to_hide_person": "Ikke i stand til at gemme person", "unable_to_link_oauth_account": "Kunne ikke tilkoble OAuth-konto", "unable_to_load_album": "Ikke i stand til hente album", - "unable_to_load_asset_activity": "Ikke i stand til at hente aktivets aktivitet", + "unable_to_load_asset_activity": "Kunne ikke hente aktivitet for mediet", "unable_to_load_items": "Ikke i stand til at hente ting", "unable_to_load_liked_status": "Ikke i stand til hente synes-om-status", "unable_to_play_video": "Ikke i stand til at afspille video", @@ -507,15 +574,15 @@ "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", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Kunne ikke fjerne offlinefiler", "unable_to_remove_library": "Ikke i stand til at fjerne bibliotek", - "unable_to_remove_offline_files": "Kunne ikke fjerne offlinefiler", "unable_to_remove_partner": "Ikke i stand til at fjerne partner", "unable_to_remove_reaction": "Ikke i stand til at reaktion", "unable_to_remove_user": "", "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": "Ikke i stand til at genoprette aktiver", + "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_save_album": "Ikke i stand til at gemme album", @@ -527,7 +594,7 @@ "unable_to_scan_library": "Ikke i stand til at skanne bibliotek", "unable_to_set_profile_picture": "Ikke i stand til at sætte profilbillede", "unable_to_submit_job": "Ikke i stand til at indsende opgave", - "unable_to_trash_asset": "Ikke i stand til at smide aktiv ud", + "unable_to_trash_asset": "Kunne ikke slette medie", "unable_to_unlink_account": "Ikke i stand til at frakoble konto", "unable_to_update_library": "Ikke i stand til at opdatere bibliotek", "unable_to_update_location": "Ikke i stand til at opdatere sted", @@ -588,7 +655,7 @@ "in_archive": "I arkiv", "include_archived": "Inkluder arkiveret", "include_shared_albums": "Inkludér delte albummer", - "include_shared_partner_assets": "Inkludér delte partneraktiver", + "include_shared_partner_assets": "Inkludér delte partnermedier", "individual_share": "Individuel andel", "info": "Info", "interval": { @@ -675,7 +742,7 @@ "no_results": "Ingen resultater", "no_shared_albums_message": "Opret et album for at dele billeder og videoer med personer i dit netværk", "not_in_any_album": "Ikke i noget album", - "note_apply_storage_label_to_previously_uploaded assets": "Bemærk: For at anvende Lagringsmærkat på tidligere uploadede aktiver, kør", + "note_apply_storage_label_to_previously_uploaded assets": "Bemærk: For at anvende Lagringsmærkat på tidligere uploadede medier, kør", "note_unlimited_quota": "Bemærk: Indsæt 0 for ubegrænset kvote", "notes": "Noter", "notification_toggle_setting_description": "Aktivér emailnotifikationer", @@ -708,9 +775,9 @@ "password_required": "Adgangskode påkrævet", "password_reset_success": "Nulstilling af adgangskode succes", "past_durations": { - "days": "Sidste {days, plural, one {dag} other {{days, number} dage}}", - "hours": "Sidste {hours, plural, one {time} other {{hours, number} timer}}", - "years": "Sidste {years, plural, one {år} other {{years, number} år}}" + "days": "Sidste {days, plural, one {dag} other {# dage}}", + "hours": "Sidste {hours, plural, one {time} other {# timer}}", + "years": "Sidste {years, plural, one {år} other {# år}}" }, "path": "Sti", "pattern": "Mønster", @@ -722,9 +789,9 @@ "people_sidebar_description": "Vis et link til Personer i sidepanelet", "perform_library_tasks": "", "permanent_deletion_warning": "Advarsel om permanent sletning", - "permanent_deletion_warning_setting_description": "Vis en advarsel, når aktiver slettes permanent", + "permanent_deletion_warning_setting_description": "Vis en advarsel, når medier slettes permanent", "permanently_delete": "Slet permanent", - "permanently_deleted_asset": "Permanent slettet aktiv", + "permanently_deleted_asset": "Permanent slettet medie", "photos": "Billeder", "photos_count": "{count, plural, one {{count, number} Billede} other {{count, number} Billeder}}", "photos_from_previous_years": "Billeder fra tidligere år", @@ -755,10 +822,10 @@ "refreshed": "Opdateret", "refreshes_every_file": "Opdaterer alle filer", "remove": "Fjern", + "remove_deleted_assets": "Fjern fra offlinefiler", "remove_from_album": "Fjern fra album", "remove_from_favorites": "Fjern fra favoritter", "remove_from_shared_link": "Fjern fra delt link", - "remove_offline_files": "Fjern fra offlinefiler", "removed_api_key": "Fjernede API-nøgle: {name}", "rename": "Omdøb", "repair": "Reparér", @@ -831,7 +898,7 @@ "shared_by_you": "Delt af dig", "shared_from_partner": "Billeder fra {partner}", "shared_links": "Delte links", - "shared_photos_and_videos_count": "{assetCount} delte billeder & videoer.", + "shared_photos_and_videos_count": "{assetCount, plural, other {# delte billeder & videoer.}}", "shared_with_partner": "Delt med {partner}", "sharing": "Delte", "sharing_sidebar_description": "Vis et link til deling i sidemenuen", @@ -868,7 +935,7 @@ "stop_photo_sharing": "Stop med at dele dine billeder?", "stop_photo_sharing_description": "{partner} vil ikke længere kunne tilgå dine billeder.", "stop_sharing_photos_with_user": "Afslut deling af dine fotos med denne bruger", - "storage": "Lagring", + "storage": "Lagringsplads", "storage_label": "Lagringsmærkat", "storage_usage": "{used} ud af {available} brugt", "submit": "Indsend", @@ -885,13 +952,13 @@ "to_archive": "Arkivér", "to_favorite": "Gør til favorit", "toggle_settings": "Slå indstillinger til eller fra", - "toggle_theme": "Slå tema til eller fra", + "toggle_theme": "Slå mørkt tema til eller fra", "toggle_visibility": "Slå synlighed til eller fra", "total_usage": "Samlet forbrug", "trash": "Papirkurv", "trash_all": "Smid alle ud", "trash_no_results_message": "Udsmidte billeder og videoer vil kunne findes her.", - "trashed_items_will_be_permanently_deleted_after": "Aktiver i skraldespand vil blive slettet permanent efter {days, plural, one {# dag} other {# dage}}.", + "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": "Uarkiveret", @@ -930,8 +997,8 @@ "view_all": "Se alle", "view_all_users": "Se alle brugere", "view_links": "Vis links", - "view_next_asset": "Se næste aktiv", - "view_previous_asset": "Se forrige aktiv", + "view_next_asset": "Se næste medie", + "view_previous_asset": "Se forrige medie", "viewer": "Viewer", "waiting": "Venter", "week": "Uge", diff --git a/web/src/lib/i18n/de.json b/i18n/de.json similarity index 81% rename from web/src/lib/i18n/de.json rename to i18n/de.json index 70f33111c0..5bff3eb817 100644 --- a/web/src/lib/i18n/de.json +++ b/i18n/de.json @@ -11,12 +11,12 @@ "add": "Hinzufügen", "add_a_description": "Beschreibung hinzufügen", "add_a_location": "Standort hinzufügen", - "add_a_name": "Name hinzufügen", + "add_a_name": "Namen hinzufügen", "add_a_title": "Titel hinzufügen", "add_exclusion_pattern": "Ausschlussmuster hinzufügen", "add_import_path": "Importpfad hinzufügen", "add_location": "Ort hinzufügen", - "add_more_users": "Mehr Nutzer hinzufügen", + "add_more_users": "Weitere Nutzer hinzufügen", "add_partner": "Partner hinzufügen", "add_path": "Pfad hinzufügen", "add_photos": "Fotos hinzufügen", @@ -27,9 +27,10 @@ "added_to_favorites": "Zu Favoriten hinzugefügt", "added_to_favorites_count": "{count, number} zu Favoriten hinzugefügt", "admin": { - "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.", + "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": "Verwaltung von Passwort-, OAuth- und sonstigen Authentifizierungseinstellungen", + "authentication_settings_description": "Passwort-, OAuth- und sonstigen 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": "Hintergrund-Aufgaben", @@ -37,67 +38,78 @@ "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?", - "confirm_delete_library_assets": "Bist du sicher, dass du diese Bibliothek löschen willst? Dies löscht alle {count, plural, one {# enthaltenes Objekt} other {alle # enthaltenen Objekte}} aus Immich und kann nicht rückgängig gemacht werden. Die Dateien bleiben auf der Festplatte erhalten.", - "confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst", + "confirm_delete_library_assets": "Bist du sicher, dass du diese Bibliothek löschen willst? Dies löscht {count, plural, one {# enthaltenes Objekt} other {alle # enthaltenen Objekte}} aus Immich und kann nicht rückgängig gemacht werden. Die Dateien bleiben auf der Festplatte erhalten.", + "confirm_email_below": "Bestätige, indem du unten \"{email}\" eingibst", "confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.", "confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?", + "create_job": "Aufgabe erstellen", "crontab_guru": "Crontab Guru", "disable_login": "Login deaktvieren", "disabled": "Deaktiviert", - "duplicate_detection_job_description": "Diese Aufgabe führt das maschinelle Lernen für jede Datei aus, um Duplikate zu finden. Diese Aufgabe beruht auf der Smart Search Technologie", - "exclusion_pattern_description": "Mit Ausschlussmustern können Dateien und Ordner beim Scannen Ihrer Bibliothek ignoriert werden. Dies ist nützlich, wenn Sie Ordner haben, die Dateien enthalten, die Sie nicht importieren möchten, wie z. B. RAW-Dateien.", + "duplicate_detection_job_description": "Diese Aufgabe führt das maschinelle Lernen für jede Datei aus, um Duplikate zu finden. Diese Aufgabe beruht auf der intelligenten Suche", + "exclusion_pattern_description": "Mit Ausschlussmustern können Dateien und Ordner beim Scannen Ihrer Bibliothek ignoriert werden. Dies ist nützlich, wenn du Ordner hast, die Dateien enthalten, die du nicht importieren möchtest, wie z. B. RAW-Dateien.", "external_library_created_at": "Externe Bibliothek (erstellt am {date})", - "external_library_management": "Externe Bibliotheksverwaltung", + "external_library_management": "Verwaltung externer Bibliotheken", "face_detection": "Gesichtserkennung", - "face_detection_description": "Diese Aufgabe erkennt Gesichter in Dateien mittels maschinellen Lernens. Bei Videos wird nur die Miniaturansicht verwendet. „Alle“ verarbeitet alle Dateien neu, während „Fehlende“ nur nicht verarbeitete Dateien in die Warteschlange stellt. Erkannte Gesichter werden zur Gruppierung in bestehende oder neue Personen in die Warteschlange gestellt.", - "facial_recognition_job_description": "Diese Aufgabe gruppiert erkannte Gesichter zu Personen nach der Gesichtserkennung. „Alle“ clustert alle Gesichter neu, während „Fehlende“ Gesichter ohne Zuordnung in die Warteschlange stellt.", + "face_detection_description": "Diese Aufgabe erkennt Gesichter in Dateien mittels maschinellen Lernens. Bei Videos wird nur die Miniaturansicht verwendet. „Aktualisieren“ verarbeitet alle Dateien neu. „Zurücksetzen“ setzt zusätzlich alle Gesichter zurück. „Fehlende“ stellt nur nicht verarbeitete Dateien in die Warteschlange. Erkannte Gesichter werden zur Gruppierung in bestehende oder neue Personen in die Warteschlange gestellt.", + "facial_recognition_job_description": "Diese Aufgabe gruppiert im Anschluss an die Gesichtserkennung die erkannten Gesichter zu Personen. „Zurücksetzen“ gruppiert alle Gesichter neu, während „Fehlende“ Gesichter ohne Zuordnung in die Warteschlange stellt.", "failed_job_command": "Befehl {command} ist für Aufgabe {job} fehlgeschlagen", "force_delete_user_warning": "WARNUNG: Diese Aktion löscht sofort den Benutzer und all seine Dateien. Dies kann nicht rückgängig gemacht werden und die Dateien können nicht wiederhergestellt werden.", "forcing_refresh_library_files": "Erneutes Laden aller Bibliotheksdateien erzwingen", - "image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist dafür aber etwas langsamer in der Verarbeitung.", + "image_format": "Format", + "image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.", "image_prefer_embedded_preview": "Eingebettete Vorschau bevorzugen", "image_prefer_embedded_preview_setting_description": "Verwende eingebettete Vorschaubilder in RAW-Fotos als Grundlage für die Bildverarbeitung, sofern diese zur Verfügung stehen. Dies kann bei einigen Bildern genauere Farben erzeugen, allerdings ist die Qualität der Vorschau kameraabhängig und das Bild kann mehr Kompressionsartefakte aufweisen.", "image_prefer_wide_gamut": "Breites Spektrum bevorzugen", - "image_prefer_wide_gamut_setting_description": "Verwendung von Display P3 (DCI-P3) für Vorschaubilder. Dadurch bleibt die Lebendigkeit von Bildern mit breiten Farbräumen besser erhalten, aber die Bilder können auf älteren Geräten mit einer älteren Browserversion etwas anders aussehen. sRGB-Bilder werden im sRGB-Format belassen, um Farbverschiebungen zu vermeiden.", - "image_preview_format": "Format-Vorschau", - "image_preview_resolution": "Vorschau der Auflösung", + "image_prefer_wide_gamut_setting_description": "Verwendung von Display P3 (DCI-P3) für Miniaturansichten. Dadurch bleibt die Lebendigkeit von Bildern mit breiten Farbräumen besser erhalten, aber die Bilder können auf älteren Geräten mit einer älteren Browserversion etwas anders aussehen. sRGB-Bilder werden im sRGB-Format belassen, um Farbverschiebungen zu vermeiden.", + "image_preview_description": "Mittelgroßes Bild mit entfernten Metadaten, das bei der Betrachtung einer einzelnen Datei und für maschinelles Lernen verwendet wird", + "image_preview_format": "Vorschauformat", + "image_preview_quality_description": "Vorschauqualität von 1-100. Ein höherer Wert ist besser, erzeugt dadurch aber größere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen. Die Einstellung eines niedrigen Wertes kann dafür aber die Qualität des maschinellen Lernens beeinträchtigen.", + "image_preview_resolution": "Vorschau-Auflösung", "image_preview_resolution_description": "Dies wird beim Anzeigen eines einzelnen Fotos und für das maschinelle Lernen verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", + "image_preview_title": "Vorschaueinstellungen", "image_quality": "Qualität", "image_quality_description": "Bildqualität von 1-100. Höher bedeutet bessere Qualität, erzeugt aber größere Dateien. Diese Option betrifft die Vorschaubilder und Miniaturansichten.", + "image_resolution": "Auflösung", + "image_resolution_description": "Höhere Auflösungen können mehr Details erhalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit von Anwendungen beeinträchtigen.", "image_settings": "Bildeinstellungen", - "image_settings_description": "Verwaltung der Qualität und Auflösung von generierten Bildern", - "image_thumbnail_format": "Vorschaubildformat", - "image_thumbnail_resolution": "Vorschaubildauflösung", + "image_settings_description": "Qualität und Auflösung von generierten Bildern verwalten", + "image_thumbnail_description": "Kleine Miniaturansicht mit entfernten Metadaten, die bei der Anzeige von Sammlungen von Fotos wie der Zeitleiste verwendet wird", + "image_thumbnail_format": "Miniaturansichts-Format", + "image_thumbnail_quality_description": "Qualität der Miniaturansicht von 1-100. Höher ist besser, erzeugt aber größere Dateien und kann die Reaktionsfähigkeit der App beeinträchtigen.", + "image_thumbnail_resolution": "Miniaturansichts-Auflösung", "image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", - "job_concurrency": "{job} - (Anzahl der Parallelitäten)", - "job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.", - "job_settings": "Job-Einstellungen", - "job_settings_description": "Verwaltung von Parallelitäten von Jobs", - "job_status": "Job-Status", + "image_thumbnail_title": "Miniaturansicht-Einstellungen", + "job_concurrency": "{job} (Anzahl gleichzeitiger Prozesse)", + "job_created": "Aufgabe erstellt", + "job_not_concurrency_safe": "Diese Aufgabe ist nicht parallelisierungssicher.", + "job_settings": "Aufgaben-Einstellungen", + "job_settings_description": "Gleichzeitige Aufgaben-Prozesse verwalten", + "job_status": "Aufgaben-Status", "jobs_delayed": "{jobCount, plural, other {# verzögert}}", "jobs_failed": "{jobCount, plural, other {# fehlgeschlagen}}", "library_created": "Bibliothek erstellt: {library}", "library_cron_expression": "Cron-Ausdruck", - "library_cron_expression_description": "Legen Sie das Überprüfungsintervall mit Hilfe des cron-Formats fest. Für weitere Informationen siehe z.B. Crontab Guru", + "library_cron_expression_description": "Lege das Überprüfungsintervall mit Hilfe des cron-Formats fest. Für weitere Informationen siehe z.B. Crontab Guru", "library_cron_expression_presets": "Cron-Expression Voreinstellungen", "library_deleted": "Bibliothek gelöscht", "library_import_path_description": "Gib einen Ordner für den Import an. Dieser Ordner, einschließlich der Unterordner, wird nach Bildern und Videos durchsucht.", - "library_scanning": "Periodisches scannen", + "library_scanning": "Periodisches Scannen", "library_scanning_description": "Regelmäßiges Durchsuchen der Bibliothek einstellen", "library_scanning_enable_description": "Regelmäßiges Scannen der Bibliothek aktivieren", "library_settings": "Externe Bibliothek", - "library_settings_description": "Verwaltung von Einstellungen externer Bibliotheken", + "library_settings_description": "Einstellungen externer Bibliotheken verwalten", "library_tasks_description": "Diese Aufgabe aktualisiert und überprüft die Bibliotheken", "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", "logging_enable_description": "Aktiviere Logging", - "logging_level_description": "Wenn aktiviert, welches Log Level genutzt wird.", + "logging_level_description": "Wenn aktiviert, welches Log-Level genutzt wird.", "logging_settings": "Protokollierung", "machine_learning_clip_model": "CLIP-Modell", - "machine_learning_clip_model_description": "Der Name eines CLIP-Modells, welches \"hier\" aufgeführt ist. Beachte, dass du den Job \"Intelligente Suche\" für alle Bilder erneut ausführen musst, wenn du das Modell wechselst.", - "machine_learning_duplicate_detection": "Duplikats-Erkennung", - "machine_learning_duplicate_detection_enabled": "Duplikat-Erkennung aktivieren", + "machine_learning_clip_model_description": "Der Name eines CLIP-Modells, welches hier aufgeführt ist. Beachte, dass du die Aufgabe \"Intelligente Suche\" für alle Bilder erneut ausführen musst, wenn du das Modell wechselst.", + "machine_learning_duplicate_detection": "Duplikaterkennung", + "machine_learning_duplicate_detection_enabled": "Duplikaterkennung aktivieren", "machine_learning_duplicate_detection_enabled_description": "Falls diese Option deaktiviert ist, werden exakt identische Dateien dennoch de-dupliziert.", "machine_learning_duplicate_detection_setting_description": "Verwendung von CLIP-Embeddings zum Erkennen möglicher Duplikate", "machine_learning_enabled": "Maschinelles Lernen aktivieren", @@ -105,49 +117,54 @@ "machine_learning_facial_recognition": "Gesichtsidentifikation", "machine_learning_facial_recognition_description": "Erkenne, identifiziere und gruppiere Gesichter in Bildern", "machine_learning_facial_recognition_model": "Gesichtserkennungs-Modell", - "machine_learning_facial_recognition_model_description": "Die Modelle sind in absteigender Reihenfolge ihrer Größe aufgeführt. Größere Modelle sind langsamer und verbrauchen mehr Speicher, liefern aber bessere Ergebnisse. Bitte beachte dabei, dass du den Gesichtserkennungsjob für alle Bilder neu starten musst, wenn du ein Modell änderst.", + "machine_learning_facial_recognition_model_description": "Die Modelle sind in absteigender Reihenfolge ihrer Größe aufgeführt. Größere Modelle sind langsamer und verbrauchen mehr Speicher, liefern aber bessere Ergebnisse. Bitte beachte dabei, dass du die Gesichtserkennungsaufgabe für alle Bilder neu starten musst, wenn du ein Modell änderst.", "machine_learning_facial_recognition_setting": "Gesichtserkennung aktivieren", "machine_learning_facial_recognition_setting_description": "Wenn diese Option deaktiviert ist, werden die Bilder nicht für die Gesichtserkennung kodiert und der Abschnitt „Personen“ auf der Seite „Erkunden“ wird nicht dargestellt.", "machine_learning_max_detection_distance": "Maximaler Erkennungsabstand", - "machine_learning_max_detection_distance_description": "Maximaler Unterschied zwischen zwei Bildern, um sie als Duplikate zu betrachten, im Bereich von 0,001-0,1. Bei höheren Werten werden mehr Duplikate erkannt, aber es kann zu falsch positiven Ergebnissen kommen.", + "machine_learning_max_detection_distance_description": "Maximaler Unterschied zwischen zwei Bildern, um sie als Duplikate zu betrachten, im Bereich von 0,001-0,1. Bei höheren Werten werden mehr Duplikate erkannt, aber es kann zu falsch-positiven Ergebnissen kommen.", "machine_learning_max_recognition_distance": "Maximaler Erkennungsabstand", "machine_learning_max_recognition_distance_description": "Maximaler Abstand zwischen zwei Gesichtern, die als dieselbe Person angesehen werden, von 0-2. Ein niedrigerer Wert kann verhindern, dass zwei Personen als dieselbe Person eingestuft werden, während ein höherer Wert verhindern kann, dass ein und dieselbe Person als zwei verschiedene Personen eingestuft wird. Bitte beachte dabei, dass es einfacher ist, zwei Personen zu verschmelzen, als eine Person in zwei zu teilen, also wähle nach Möglichkeit einen niedrigeren Schwellenwert.", "machine_learning_min_detection_score": "Minimale Erkennungsrate", "machine_learning_min_detection_score_description": "Minimale Konfidenzrate für die Erkennung eines Gesichts von 0-1. Bei niedrigeren Werten werden mehr Gesichter erkannt, aber es kann zu falsch-positiven Ergebnissen kommen.", "machine_learning_min_recognized_faces": "Mindestens erkannte Gesichter", - "machine_learning_min_recognized_faces_description": "Die Mindestanzahl von erkannten Gesichtern, damit eine Person erstellt werden kann. Eine Erhöhung dieses Wertes macht die Gesichtserkennung präziser, erhöht aber die Wahrscheinlichkeit, dass ein Gesicht nicht zu einer Person zugeordnet werden kann.", + "machine_learning_min_recognized_faces_description": "Die Mindestanzahl von erkannten Gesichtern, damit eine Person erstellt werden kann. Eine Erhöhung dieses Wertes macht die Gesichtserkennung präziser, erhöht aber die Wahrscheinlichkeit, dass ein Gesicht nicht zu einer Person zugeordnet wird.", "machine_learning_settings": "Einstellungen für maschinelles Lernen", - "machine_learning_settings_description": "Verwaltung von Funktionen und Einstellungen für das maschinelle Lernen", + "machine_learning_settings_description": "Funktionen und Einstellungen des maschinellen Lernens verwalten", "machine_learning_smart_search": "Intelligente Suche", - "machine_learning_smart_search_description": "Semantische Bildsuche mit CLIP-Einbettungen", + "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": "Server-URL für maschinelles Lernen", "manage_concurrency": "Gleichzeitige Ausführungen verwalten", - "manage_log_settings": "Verwaltung der Immich Log-Einstellungen", + "manage_log_settings": "Log-Einstellungen verwalten", "map_dark_style": "Dunkler Stil", "map_enable_description": "Kartenfunktionen aktivieren", - "map_gps_settings": "Karten & GPS Einstellungen", - "map_gps_settings_description": "Karten & GPS Einstellungen verwalten", + "map_gps_settings": "Karten- & GPS-Einstellungen", + "map_gps_settings_description": "Karten- & GPS-Einstellungen verwalten", + "map_implications": "Die Kartenfunktion verwendet einen externen Tile-Service (tiles.immich.cloud)", "map_light_style": "Heller Stil", - "map_manage_reverse_geocoding_settings": "Einstellungen für die Umgekehrte Geokodierung verwalten", + "map_manage_reverse_geocoding_settings": "Einstellungen für die umgekehrte Geokodierung verwalten", "map_reverse_geocoding": "Umgekehrte Geokodierung", "map_reverse_geocoding_enable_description": "Umgekehrte Geokodierung aktivieren", - "map_reverse_geocoding_settings": "Einstellungen für Umgekehrte Geokodierung", - "map_settings": "Karten Einstellungen", - "map_settings_description": "Verwaltung der Karten- & GPS Einstellungen", + "map_reverse_geocoding_settings": "Einstellungen für umgekehrte Geokodierung", + "map_settings": "Karte", + "map_settings_description": "Karten- und GPS-Einstellungen verwalten", "map_style_description": "URL zu einem style.json Karten-Theme", "metadata_extraction_job": "Metadaten extrahieren", - "metadata_extraction_job_description": "Extrahieren von Metadaten, wie zum Beispiel GPS und Auflösung aus jeder Datei", + "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", + "metadata_faces_import_setting_description": "Gesichter aus EXIF-Daten des Bildes und Sidecar-Dateien importieren", + "metadata_settings": "Metadaten-Einstellungen", + "metadata_settings_description": "Metadaten-Einstellungen verwalten", "migration_job": "Migration", - "migration_job_description": "Diese Aufgabe migriert Vorschaubilder für Dateien und Gesichter in die neueste Ordnerstruktur", + "migration_job_description": "Diese Aufgabe migriert Miniaturansichten für Dateien und Gesichter in die neueste Ordnerstruktur", "no_paths_added": "Keine Pfade hinzugefügt", - "no_pattern_added": "Kein Pattern hinzugefügt", - "note_apply_storage_label_previous_assets": "Hinweis: Um das Storage Label auf die vorher hochgeladenen Dateien anzuwenden, starte den", + "no_pattern_added": "Kein Ausschlussmuster hinzugefügt", + "note_apply_storage_label_previous_assets": "Hinweis: Um den Speicherpfad auf die vorher hochgeladenen Dateien anzuwenden, starte den", "note_cannot_be_changed_later": "HINWEIS: Dies kann später nicht mehr geändert werden!", "note_unlimited_quota": "Hinweis: 0 eingeben für unlimitiertes Kontingent", - "notification_email_from_address": "Von", - "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", + "notification_email_from_address": "Absenderadresse", + "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", "notification_email_host_description": "Host des E-Mail-Servers (z.B. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoriere Zertifikats-Fehler", "notification_email_ignore_certificate_errors_description": "TLS-Zertifikatsvalidierungsfehler ignorieren (nicht empfohlen)", @@ -161,21 +178,21 @@ "notification_email_username_description": "Benutzername, der bei der Anmeldung am E-Mail-Server verwendet wird", "notification_enable_email_notifications": "E-Mail-Benachrichtigungen aktivieren", "notification_settings": "Benachrichtigungseinstellungen", - "notification_settings_description": "Verwaltung der Benachrichtigungseinstellungen (incl. E-Mail)", + "notification_settings_description": "Benachrichtigungseinstellungen (inkl. E-Mail) verwalten", "oauth_auto_launch": "Auto-Start", "oauth_auto_launch_description": "Automatischer Start des OAuth-Anmeldevorgangs beim Aufrufen der Anmeldeseite", "oauth_auto_register": "Automatische Registrierung", "oauth_auto_register_description": "Automatische Registrierung neuer Benutzer nach der OAuth-Anmeldung", - "oauth_button_text": "Button Text", - "oauth_client_id": "Client ID", + "oauth_button_text": "Button-Text", + "oauth_client_id": "Client-ID", "oauth_client_secret": "Client-Geheimnis", "oauth_enable_description": "Anmeldung mit OAuth", "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 'app.immich:/' ein ungültiger Redirect-URI ist.", + "oauth_mobile_redirect_uri_override_description": "Einschalten, wenn der OAuth-Provider keine mobile URI wie '{callback}' erlaubt", "oauth_profile_signing_algorithm": "Algorithmus zur Profilsignierung", - "oauth_profile_signing_algorithm_description": "Dieser Algorithmus wird für die für die Signatur des Benutzerprofils verwendet.", + "oauth_profile_signing_algorithm_description": "Dieser Algorithmus wird für die Signatur des Benutzerprofils verwendet.", "oauth_scope": "Umfang", "oauth_settings": "OAuth", "oauth_settings_description": "OAuth-Anmeldeeinstellungen verwalten", @@ -186,26 +203,29 @@ "oauth_storage_quota_claim": "Speicherkontingentangabe", "oauth_storage_quota_claim_description": "Setzen Sie das Speicherkontingent des Benutzers automatisch auf den angegebenen Wert.", "oauth_storage_quota_default": "Standard-Speicherplatzkontingent (GiB)", - "oauth_storage_quota_default_description": "Kontingent in GiB, welcher verwendet werden kann, wenn kein Anspruch erhoben wurde (Gib 0 für einen unbegrenzten Speicherkontingent ein).", + "oauth_storage_quota_default_description": "Kontingent in GiB, das verwendet werden soll, wenn keines übermittelt wird (gib 0 für ein unbegrenztes Kontingent ein).", "offline_paths": "Offline-Pfade", - "offline_paths_description": "Die Ergebnisse könnten durch manuelles Löschen von Dateien, die nicht Teil einer externen Bibliothek sein, verursacht sein.", + "offline_paths_description": "Die Ergebnisse könnten durch manuelles Löschen von Dateien, die nicht Teil einer externen Bibliothek sind, verursacht sein.", "password_enable_description": "Login mit E-Mail und Passwort", - "password_settings": "Passwort Login", + "password_settings": "Passwort-Login", "password_settings_description": "Passwort-Anmeldeeinstellungen verwalten", "paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert", + "person_cleanup_job": "Personen aufräumen", "quota_size_gib": "Kontingent (GiB)", "refreshing_all_libraries": "Alle Bibliotheken aktualisieren", "registration": "Admin-Registrierung", "registration_description": "Da du der erste Benutzer im System bist, wirst du als Admin zugewiesen und bist für administrative Aufgaben zuständig. Weitere Benutzer werden von dir erstellt.", - "removing_offline_files": "Offline-Dateien entfernen", + "removing_deleted_files": "Offline-Dateien entfernen", "repair_all": "Alle reparieren", "repair_matched_items": "{count, plural, one {# Eintrag} other {# Einträge}} gefunden", "repaired_items": "{count, plural, one {# Eintrag} other {# Einträge}} repariert", "require_password_change_on_login": "Benutzer muss das Passwort beim ersten Login ändern", "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", "scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien", "scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien", + "search_jobs": "Aufgaben suchen...", "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)://", @@ -216,7 +236,7 @@ "sidecar_job": "Filialdatei-Metadaten", "sidecar_job_description": "Durch diese Aufgabe werden Filialdatei-Metadaten im Dateisystem entdeckt oder synchronisiert", "slideshow_duration_description": "Dauer der Anzeige jedes Bildes in Sekunden", - "smart_search_job_description": "Diese Aufgabe wendet das maschinelles Lernen auf Dateien an, um die intelligente Suche zu ermöglichen", + "smart_search_job_description": "Diese Aufgabe wendet das maschinelle Lernen auf Dateien an, um die intelligente Suche zu ermöglichen", "storage_template_date_time_description": "Der Erstellungszeitstempel der Datei wird für die Datums- und Uhrzeitinformation verwendet", "storage_template_date_time_sample": "Beispielzeitpunkt {date}", "storage_template_enable_description": "Speichervorlagen-Engine aktivieren", @@ -225,21 +245,22 @@ "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_job": "Speichervorlagenmigrations-Job", - "storage_template_more_details": "Weitere Details zu dieser Funktion finden Sie unter Speichervorlage und dessen Implikationen", + "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.", - "storage_template_path_length": "Ungefähres Pfad Längen Limit: {length, number}/{limit, number}", + "storage_template_path_length": "Ungefähres Pfadlängen-Limit: {length, number}/{limit, number}", "storage_template_settings": "Speichervorlage", "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", - "storage_template_user_label": "{label} is das Speicher-Label des Benutzers", - "system_settings": "System-Einstellungen", + "storage_template_user_label": "{label} is die Speicherpfadbezeichnung des Benutzers", + "system_settings": "Systemeinstellungen", + "tag_cleanup_job": "Tags aufräumen", "theme_custom_css_settings": "Benutzerdefiniertes CSS", "theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.", "theme_settings": "Theme-Einstellungen", "theme_settings_description": "Anpassung der Immich-Web-Oberfläche", "these_files_matched_by_checksum": "Diese Dateien wurden anhand ihrer Prüfsummen abgeglichen", - "thumbnail_generation_job": "Vorschaubilder generieren", - "thumbnail_generation_job_description": "Diese Aufgabe erzeugt große, kleine und unscharfe Miniaturbilder für jede einzelne Datei, sowie Miniaturbilder für jede Person", + "thumbnail_generation_job": "Miniaturansichten generieren", + "thumbnail_generation_job_description": "Diese Aufgabe erzeugt große, kleine und unscharfe Miniaturansichten für jede einzelne Datei, sowie Miniaturansichten für jede Person", "transcode_policy_description": "Richtlinien, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).", "transcoding_acceleration_api": "Beschleunigungs-API", "transcoding_acceleration_api_description": "Die Schnittstelle welche mit dem Gerät interagiert, um die Transkodierung zu beschleunigen. Bei dieser Einstellung handelt es sich um die \"bestmögliche Lösung\": Bei einem Fehler wird auf die Software-Transkodierung zurückgegriffen. Abhängig von der verwendeten Hardware kann VP9 funktionieren oder auch nicht.", @@ -266,7 +287,7 @@ "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", - "transcoding_hardware_decoding_setting_description": "Nur gültig für NVENC, QSV und RKMPP. Ermöglicht eine Ende-zu-Ende-Beschleunigung, anstatt nur die Codierung zu beschleunigen. Dies funktioniert möglicherweise nicht bei allen Videos.", + "transcoding_hardware_decoding_setting_description": "Ermöglicht eine Ende-zu-Ende-Beschleunigung, anstatt nur die Codierung zu beschleunigen. Dies funktioniert möglicherweise nicht bei allen Videos.", "transcoding_hevc_codec": "HEVC-Codec", "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.", @@ -277,22 +298,22 @@ "transcoding_optimal_description": "Videos mit einer höheren Auflösung als der Zielauflösung oder in einem nicht akzeptierten Format", "transcoding_preferred_hardware_device": "Bevorzugtes Hardwaregerät", "transcoding_preferred_hardware_device_description": "Gilt nur für VAAPI und QSV. Legt den für die Hardware-Transkodierung verwendeten dri-Node fest.", - "transcoding_preset_preset": "Voreinstellung (-voreinstellung)", + "transcoding_preset_preset": "Voreinstellung (-preset)", "transcoding_preset_preset_description": "Komprimierungsgeschwindigkeit. Eine langsamere Voreinstellungen erzeugt kleinere Dateien und erhöht die Qualität, wenn man eine gewisse Bitrate anstrebt. VP9 ignoriert Geschwindigkeiten über „Schneller“.", "transcoding_reference_frames": "Referenz-Frames", "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": "Video-Transkodierungseinstellungen", - "transcoding_settings_description": "Verwalten der Auflösungs- und Kodierungsinformationen von Videodateien", + "transcoding_settings_description": "Auflösungs- und Kodierungsinformationen von Videodateien verwalten", "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", "transcoding_temporal_aq_description": "Gilt nur für NVENC. Verbessert die Qualität von Szenen mit hohem Detailreichtum und geringen Bewegungen. Dies ist möglicherweise nicht mit älteren Geräten kompatibel.", "transcoding_threads": "Threads", "transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Codierung, lassen dem Server aber weniger Spielraum für die Verarbeitung anderer Aufgaben, solange dies aktiv ist. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Nutzt die maximale Auslastung, wenn der Wert auf 0 gesetzt ist.", - "transcoding_tone_mapping": "Farbton-mapping", + "transcoding_tone_mapping": "Farbton-Mapping", "transcoding_tone_mapping_description": "Versucht, das Aussehen von HDR-Videos bei der Konvertierung in SDR beizubehalten. Jeder Algorithmus geht unterschiedliche Kompromisse bei Farbe, Details und Helligkeit ein. Hable bewahrt Details, Mobius bewahrt die Farbe und Reinhard bewahrt die Helligkeit.", - "transcoding_tone_mapping_npl": "Farbton-mapping NPL", + "transcoding_tone_mapping_npl": "Farbton-Mapping NPL", "transcoding_tone_mapping_npl_description": "Die Farben werden so angepasst, dass sie für einen Bildschirm mit entsprechender Helligkeit normal aussehen. Entgegen der Annahme, dass niedrigere Werte die Helligkeit des Videos erhöhen und umgekehrt, wird die Helligkeit des Bildschirms ausgeglichen. Mit 0 wird dieser Wert automatisch eingestellt.", "transcoding_transcode_policy": "Transcodierungsrichtlinie", "transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).", @@ -306,7 +327,8 @@ "trash_settings": "Papierkorb-Einstellungen", "trash_settings_description": "Papierkorb-Einstellungen verwalten", "untracked_files": "Unverfolgte Dateien", - "untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", + "untracked_files_description": "Diese Dateien werden nicht von der Anwendung getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", + "user_cleanup_job": "Benutzer aufräumen", "user_delete_delay": "Das Konto und die Dateien von {user} werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.", "user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern", "user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.", @@ -320,7 +342,8 @@ "user_settings": "Benutzer-Einstellungen", "user_settings_description": "Benutzer-Einstellungen verwalten", "user_successfully_removed": "Benutzer {email} wurde erfolgreich entfernt.", - "version_check_enabled_description": "Regelmäßige Abfragen gegen GitHub aktivieren, um nach neueren Versionen zu prüfen", + "version_check_enabled_description": "Versionsprüfung aktivieren", + "version_check_implications": "Die Funktion zur Versionsprüfung basiert auf regelmäßiger Kommunikation mit GitHub.com", "version_check_settings": "Versionsprüfung", "version_check_settings_description": "Aktivieren/Deaktivieren der Benachrichtigung über neue Versionen", "video_conversion_job": "Videos transkodieren", @@ -336,12 +359,13 @@ "album_added": "Album hinzugefügt", "album_added_notification_setting_description": "Erhalte eine E-Mail-Benachrichtigung, wenn du zu einem freigegebenen Album hinzugefügt wurdest", "album_cover_updated": "Album-Cover aktualisiert", - "album_delete_confirmation": "Bist du sicher, dass du das Album {album} löschen willst?\nWenn dieses Album geteilt wurde, können andere Benutzer nicht mehr darauf zugreifen.", + "album_delete_confirmation": "Bist du sicher, dass du das Album {album} löschen willst?", + "album_delete_confirmation_description": "Falls dieses Album geteilt wurde, können andere Benutzer nicht mehr darauf zugreifen.", "album_info_updated": "Album-Infos aktualisiert", "album_leave": "Album verlassen?", "album_leave_confirmation": "Bist du sicher, dass du das Album {album} verlassen willst?", - "album_name": "Album Name", - "album_options": "Album Optionen", + "album_name": "Albumname", + "album_options": "Albumoptionen", "album_remove_user": "Nutzer entfernen?", "album_remove_user_confirmation": "Bist du sicher, dass du {user} entfernen willst?", "album_share_no_users": "Es sieht so aus, als hättest du dieses Album mit allen Benutzern geteilt oder du hast keine Benutzer, mit denen du teilen kannst.", @@ -349,7 +373,7 @@ "album_updated_setting_description": "Erhalte eine E-Mail-Benachrichtigung, wenn ein freigegebenes Album neue Dateien enthält", "album_user_left": "{album} verlassen", "album_user_removed": "{user} entfernt", - "album_with_link_access": "Lass jeden mit dem Link Fotos und Personen in diesem Album sehen.", + "album_with_link_access": "Lass jeden mit dem Link die Fotos und Personen in diesem Album sehen.", "albums": "Alben", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Alben}}", "all": "Alle", @@ -360,6 +384,7 @@ "allow_edits": "Bearbeiten erlauben", "allow_public_user_to_download": "Erlaube öffentlichen Benutzern, herunterzuladen", "allow_public_user_to_upload": "Erlaube öffentlichen Benutzern, hochzuladen", + "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.", "api_key_empty": "Dein API-Schlüssel-Name darf nicht leer sein", @@ -371,7 +396,7 @@ "archive_size": "Archivgröße", "archive_size_description": "Archivgröße für Downloads konfigurieren (in GiB)", "archived": "Archiviert", - "archived_count": "{count, plural, other {# Archiviert}}", + "archived_count": "{count, plural, other {# archiviert}}", "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", @@ -381,35 +406,37 @@ "asset_has_unassigned_faces": "Datei hat nicht zugewiesene Gesichter", "asset_hashing": "Berechnung des Hashwerts...", "asset_offline": "Datei offline", - "asset_offline_description": "Diese Datei ist nicht erreichbar. Immich kann nicht auf ihren Speicherort zugreifen. Bitte stelle sicher, dass die Datei verfügbar ist und scanne die Bibliothek erneut.", + "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...", "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", - "assets_added_to_name_count": "{count, plural, one {# Element} other {# Elemente}} zu {hasName, select, true {{name}} other {neuen Album}} hinzugefügt", + "assets_added_to_name_count": "{count, plural, one {# Element} other {# Elemente}} zu {hasName, select, true {{name}} other {neuem Album}} hinzugefügt", "assets_count": "{count, plural, one {# Datei} other {# Dateien}}", "assets_moved_to_trash": "{count, plural, one {# Datei} other {# Dateien}} in den Papierkorb verschoben", "assets_moved_to_trash_count": "{count, plural, one {# Datei} other {# Dateien}} in den Papierkorb verschoben", "assets_permanently_deleted_count": "{count, plural, one {# Datei} other {# Dateien}} dauerhaft gelöscht", "assets_removed_count": "{count, plural, one {# Datei} other {# Dateien}} entfernt", - "assets_restore_confirmation": "Bist du sicher, dass du alle Dateien aus dem Papierkorb wiederherstellen willst? Diese Aktion kann nicht rückgängig gemacht werden!", + "assets_restore_confirmation": "Bist du sicher, dass du alle Dateien aus dem Papierkorb wiederherstellen willst? Diese Aktion kann nicht rückgängig gemacht werden! Beachte, dass Offline-Dateien auf diese Weise nicht wiederhergestellt werden können.", "assets_restored_count": "{count, plural, one {# Datei} other {# Dateien}} wiederhergestellt", "assets_trashed_count": "{count, plural, one {# Datei} other {# Dateien}} in den Papierkorb verschoben", "assets_were_part_of_album_count": "{count, plural, one {# Datei ist} other {# Dateien sind}} bereits im Album vorhanden", "authorized_devices": "Verwendete Geräte", "back": "Zurück", "back_close_deselect": "Zurück, Schließen oder Abwählen", - "backward": "Zurück", + "backward": "Rückwärts", "birthdate_saved": "Geburtsdatum erfolgreich gespeichert", "birthdate_set_description": "Das Geburtsdatum wird verwendet, um das Alter dieser Person zum Zeitpunkt eines Fotos zu berechnen.", "blurred_background": "Unscharfer Hintergrund", + "bugs_and_feature_requests": "Fehler & Verbesserungsvorschläge", "build": "Build", "build_image": "Build Abbild", - "bulk_delete_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien}} gemeinsam löschen möchtest? Dabei wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!", + "bulk_delete_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} löschen möchtest? Dabei wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden!", "bulk_keep_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien}} behalten möchtest? Dies wird alle Duplikat-Gruppen auflösen ohne etwas zu löschen.", - "bulk_trash_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien}} gemeinsam in den Papierkorb verschieben möchtest? Dies wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate in den Papierkorb verschieben.", + "bulk_trash_duplicates_confirmation": "Bist du sicher, dass du {count, plural, one {# duplizierte Datei} other {# duplizierte Dateien gemeinsam}} in den Papierkorb verschieben möchtest? Dies wird die größte Datei jeder Gruppe behalten und alle anderen Duplikate in den Papierkorb verschieben.", "buy": "Immich erwerben", "camera": "Kamera", "camera_brand": "Kamera-Marke", @@ -428,7 +455,7 @@ "change_location": "Ort ändern", "change_name": "Name ändern", "change_name_successfully": "Name wurde erfolgreich geändert", - "change_password": "Passwort Ändern", + "change_password": "Passwort ändern", "change_password_description": "Dies ist entweder das erste Mal, dass du dich im System anmeldest, oder es wurde eine Anfrage zur Änderung deines Passworts gestellt. Bitte gib unten dein neues Passwort ein.", "change_your_password": "Ändere dein Passwort", "changed_visibility_successfully": "Die Sichtbarkeit wurde erfolgreich geändert", @@ -441,23 +468,25 @@ "clear_all_recent_searches": "Alle letzten Suchvorgänge löschen", "clear_message": "Nachrichten leeren", "clear_value": "Wert leeren", + "clockwise": "Im Uhrzeigersinn", "close": "Schließen", "collapse": "Zusammenklappen", - "collapse_all": "Alles aufklappen", + "collapse_all": "Alle zusammenklappen", + "color": "Farbe", "color_theme": "Farb-Theme", "comment_deleted": "Kommentar gelöscht", - "comment_options": "Kommentar-Optionen", + "comment_options": "Kommentaroptionen", "comments_and_likes": "Kommentare & Likes", "comments_are_disabled": "Kommentare sind deaktiviert", "confirm": "Bestätigen", "confirm_admin_password": "Administrator Passwort bestätigen", "confirm_delete_shared_link": "Bist du sicher, dass du diesen geteilten Link löschen willst?", "confirm_password": "Passwort bestätigen", - "contain": "Enthält", + "contain": "Vollständig", "context": "Kontext", "continue": "Fortsetzen", "copied_image_to_clipboard": "Das Bild wurde in die Zwischenablage kopiert.", - "copied_to_clipboard": "In Zwischenablage kopiert!", + "copied_to_clipboard": "In die Zwischenablage kopiert!", "copy_error": "Kopier-Fehler", "copy_file_path": "Dateipfad kopieren", "copy_image": "Bild kopieren", @@ -477,6 +506,8 @@ "create_new_person": "Neue Person anlegen", "create_new_person_hint": "Ausgewählte Dateien einer neuen Person zuweisen", "create_new_user": "Neuen Nutzer erstellen", + "create_tag": "Tag erstellen", + "create_tag_description": "Erstelle einen neuen Tag. Für verschachtelte Tags, gib den gesamten Pfad inklusive Schrägstrich an.", "create_user": "Nutzer erstellen", "created": "Erstellt", "current_device": "Aktuelles Gerät", @@ -500,26 +531,33 @@ "delete_library": "Bibliothek löschen", "delete_link": "Link löschen", "delete_shared_link": "geteilten Link löschen", + "delete_tag": "Tag löschen", + "delete_tag_confirmation_prompt": "Bist du sicher, dass der Tag {tagName} gelöscht werden soll?", "delete_user": "Nutzer löschen", "deleted_shared_link": "Geteilten Link gelöscht", + "deletes_missing_assets": "Löscht Dateien, die auf der Festplatte fehlen", "description": "Beschreibung", "details": "Details", "direction": "Richtung", "disabled": "Deaktiviert", "disallow_edits": "Bearbeitungen verbieten", + "discord": "Discord", "discover": "Entdecken", "dismiss_all_errors": "Alle Fehler ignorieren", "dismiss_error": "Fehler ignorieren", "display_options": "Anzeigeoptionen", "display_order": "Anzeigereihenfolge", "display_original_photos": "Originale Fotos anzeigen", - "display_original_photos_setting_description": "Bei der Anzeige eines Bildes wird bevorzugt das Originalfoto statt der Miniaturansicht angezeigt, sofern das Original webkompatibel ist. Dies kann zu einer langsameren Ladezeit der Fotos führen.", + "display_original_photos_setting_description": "Bei der Anzeige eines Bildes wird bevorzugt das Originalfoto statt der Miniaturansicht angezeigt, sofern das Original webkompatibel ist. Dies kann zu einer längeren Ladezeit der Fotos führen.", "do_not_show_again": "Diese Nachricht nicht erneut anzeigen", - "done": "Erledigt", - "download": "Download", + "documentation": "Dokumentation", + "done": "Fertig", + "download": "Herunterladen", + "download_include_embedded_motion_videos": "Eingebettete Videos", + "download_include_embedded_motion_videos_description": "Videos, die in Bewegungsfotos eingebettet sind, als separate Datei einfügen", "download_settings": "Download", - "download_settings_description": "Verwaltung der Einstellungen für den Dateidownload", - "downloading": "Downloaden", + "download_settings_description": "Einstellungen für das Herunterladen von Dateien verwalten", + "downloading": "Herunterladen", "downloading_asset_filename": "Datei {filename} wird heruntergeladen", "drop_files_to_upload": "Lade Dateien hoch, indem du sie hierhin ziehst", "duplicates": "Duplikate", @@ -546,10 +584,15 @@ "edit_location": "Standort bearbeiten", "edit_name": "Name bearbeiten", "edit_people": "Personen bearbeiten", + "edit_tag": "Tag bearbeiten", "edit_title": "Titel bearbeiten", "edit_user": "Nutzer bearbeiten", "edited": "Bearbeitet", "editor": "Bearbeiter", + "editor_close_without_save_prompt": "Die Änderungen werden nicht gespeichert", + "editor_close_without_save_title": "Editor schließen?", + "editor_crop_tool_h2_aspect_ratios": "Seitenverhältnisse", + "editor_crop_tool_h2_rotation": "Drehung", "email": "E-Mail", "empty": "Leer", "empty_album": "Leeres Album", @@ -596,7 +639,7 @@ "incorrect_email_or_password": "Ungültige E-Mail oder Passwort", "paths_validation_failed": "{paths, plural, one {# Pfad konnte} other {# Pfade konnten}} nicht validiert werden", "profile_picture_transparent_pixels": "Profilbilder dürfen keine transparenten Pixel haben. Bitte zoome heran und/oder verschiebe das Bild.", - "quota_higher_than_disk_size": "Dein festgelegtes Kontingent ist grösser als der verfügbare Speicher", + "quota_higher_than_disk_size": "Dein festgelegtes Kontingent ist größer als der verfügbare Speicher", "repair_unable_to_check_items": "{count, select, one {Eintrag konnte} other {Einträge konnten}} nicht überprüft werden", "unable_to_add_album_users": "Benutzer konnten nicht zum Album hinzugefügt werden", "unable_to_add_assets_to_shared_link": "Datei konnte nicht zum geteilten Link hinzugefügt werden", @@ -618,7 +661,7 @@ "unable_to_complete_oauth_login": "OAuth-Anmeldung konnte nicht abgeschlossen werden", "unable_to_connect": "Verbindung konnte nicht hergestellt werden", "unable_to_connect_to_server": "Verbindung zum Server konnte nicht hergestellt werden", - "unable_to_copy_to_clipboard": "Konnte nicht in die Zwischenablage kopieren, stelle sicher, dass du per https auf die Seite zugreiffst", + "unable_to_copy_to_clipboard": "Konnte nicht in die Zwischenablage kopieren, stelle sicher, dass du per https auf die Seite zugreifst", "unable_to_create_admin_account": "Administratorkonto konnte nicht erstellt werden", "unable_to_create_api_key": "Es konnte kein API-Schlüssel erstellt werden", "unable_to_create_library": "Bibliothek konnte nicht erstellt werden", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Anzahl der Kommentare konnte nicht abgerufen werden", "unable_to_get_shared_link": "Fehler beim Abrufen des Freigabelinks", "unable_to_hide_person": "Person kann nicht versteckt werden", + "unable_to_link_motion_video": "Bewegungsvideo kann nicht verknüpft werden", "unable_to_link_oauth_account": "OAuth-Konto kann nicht verknüpft werden", "unable_to_load_album": "Album kann nicht geladen werden", "unable_to_load_asset_activity": "Foto-Aktivität konnte nicht geladen werden", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "API-Schlüssel konnte nicht entfernt werden", "unable_to_remove_assets_from_shared_link": "Dateien konnten nicht von geteiltem Link entfernt werden", "unable_to_remove_comment": "Kommentar kann nicht entfernt werden", + "unable_to_remove_deleted_assets": "Offline-Dateien konnten nicht entfernt werden", "unable_to_remove_library": "Bibliothek kann nicht entfernt werden", - "unable_to_remove_offline_files": "Offline-Dateien konnten nicht entfernt werden", "unable_to_remove_partner": "Partner kann nicht entfernt werden", "unable_to_remove_reaction": "Reaktion kann nicht entfernt werden", "unable_to_remove_user": "Benutzer kann nicht entfernt werden", @@ -676,9 +720,10 @@ "unable_to_scan_library": "Bibliothek konnte nicht gescannt werden", "unable_to_set_feature_photo": "Hauptfoto konnte nicht festgelegt werden", "unable_to_set_profile_picture": "Profilbild konnte nicht gesetzt werden", - "unable_to_submit_job": "Auftrag konnte nicht übermittelt werden", + "unable_to_submit_job": "Aufgabe konnte nicht eingereicht werden", "unable_to_trash_asset": "Objekte konnten nicht gelöscht werden", "unable_to_unlink_account": "Die Verknüpfung des Kontos kann nicht aufgehoben werden", + "unable_to_unlink_motion_video": "Verknüpfung zum Bewegungsvideo kann nicht aufgehoben werden", "unable_to_update_album_cover": "Album-Cover konnte nicht aktualisiert werden", "unable_to_update_album_info": "Album-Info konnte nicht aktualisiert werden", "unable_to_update_library": "Die Bibliothek konnte nicht aktualisiert werden", @@ -694,11 +739,12 @@ "every_six_hours": "Alle 6 Stunden", "exif": "EXIF", "exit_slideshow": "Diashow beenden", - "expand_all": "Alle erweitern", + "expand_all": "Alle aufklappen", "expire_after": "Verfällt nach", "expired": "Verfallen", "expires_date": "Läuft am {date} ab", "explore": "Erkunden", + "explorer": "Datei-Explorer", "export": "Exportieren", "export_as_json": "Als JSON exportieren", "extension": "Erweiterung", @@ -712,6 +758,8 @@ "feature": "Funktion", "feature_photo_updated": "Profilbild aktualisiert", "featurecollection": "Funktionssammlung", + "features": "Funktionen", + "features_setting_description": "Funktionen der App verwalten", "file_name": "Dateiname", "file_name_or_extension": "Dateiname oder -erweiterung", "filename": "Dateiname", @@ -720,10 +768,12 @@ "filter_people": "Personen filtern", "find_them_fast": "Finde sie schneller mit der Suche nach Namen", "fix_incorrect_match": "Fehlerhafte Übereinstimmung beheben", + "folders": "Ordner", + "folders_feature_description": "Durchsuchen der Ordneransicht für Fotos und Videos im Dateisystem", "force_re-scan_library_files": "Erzwingen des erneuten Scannens aller Bibliotheksdateien", - "forward": "Weiterleiten", + "forward": "Vorwärts", "general": "Allgemein", - "get_help": "Erhalte Hilfe", + "get_help": "Hilfe erhalten", "getting_started": "Erste Schritte", "go_back": "Zurück", "go_to_search": "Zur Suche gehen", @@ -758,7 +808,7 @@ "image_taken": "{isVideo, select, true {Video aufgenommen} other {Bild aufgenommen}}", "img": "Img", "immich_logo": "Immich-Logo", - "immich_web_interface": "Immich Webschnittstelle", + "immich_web_interface": "Immich-Web-Oberfläche", "import_from_json": "Aus JSON importieren", "import_path": "Importpfad", "in_albums": "In {count, plural, one {# Album} other {# Alben}}", @@ -769,10 +819,10 @@ "individual_share": "Individuelle Freigabe", "info": "Info", "interval": { - "day_at_onepm": "Täglich 13.00 Uhr", + "day_at_onepm": "Täglich um 13:00 Uhr", "hours": "{hours, plural, one {Jede Stunde} other {Alle {hours, number} Stunden}}", "night_at_midnight": "Täglich um Mitternacht", - "night_at_twoam": "Täglich Nachts um 2.00 Uhr" + "night_at_twoam": "Täglich nachts um 2:00 Uhr" }, "invite_people": "Personen einladen", "invite_to_album": "Zum Album einladen", @@ -819,6 +869,7 @@ "license_trial_info_4": "Bitte erwäge den Kauf einer Lizenz, um die kontinuierliche Weiterentwicklung des Dienstes zu unterstützen", "light": "Hell", "like_deleted": "Like gelöscht", + "link_motion_video": "Bewegungsvideo verknüpfen", "link_options": "Link-Optionen", "link_to_oauth": "Link zu OAuth", "linked_oauth_account": "Verknüpftes OAuth-Konto", @@ -837,17 +888,18 @@ "look": "Erscheinungsbild", "loop_videos": "Loop-Videos", "loop_videos_description": "Aktiviere diese Option, um eine automatische Videoschleife in der Detailansicht zu erstellen.", + "main_branch_warning": "Du benutzt eine Entwicklungsversion. Wir empfehlen dringend, eine Release-Version zu verwenden!", "make": "Marke", "manage_shared_links": "Freigegebene Links verwalten", "manage_sharing_with_partners": "Gemeinsame Nutzung mit Partnern verwalten", - "manage_the_app_settings": "Verwalten der App-Einstellungen", + "manage_the_app_settings": "App-Einstellungen verwalten", "manage_your_account": "Dein Konto verwalten", "manage_your_api_keys": "Deine API-Schlüssel verwalten", - "manage_your_devices": "Verwalte Deine eingeloggten Geräte", + "manage_your_devices": "Deine eingeloggten Geräte verwalten", "manage_your_oauth_connection": "Deine OAuth-Verbindung verwalten", "map": "Karte", - "map_marker_for_images": "Kartemarkierung für Bilder, die in {city}, {country} aufgenommen wurden", - "map_marker_with_image": "Kartenmarker mit Bild", + "map_marker_for_images": "Kartenmarkierung für Bilder, die in {city}, {country} aufgenommen wurden", + "map_marker_with_image": "Kartenmarkierung mit Bild", "map_settings": "Karteneinstellungen", "matches": "Treffer", "media_type": "Medientyp", @@ -888,9 +940,9 @@ "no_albums_yet": "Es sieht so aus, als hättest du noch keine Alben.", "no_archived_assets_message": "Archiviere Fotos und Videos, um sie aus deiner Fotoansicht zu entfernen", "no_assets_message": "KLICKE, UM DEIN ERSTES FOTO HOCHZULADEN", - "no_duplicates_found": "Keine Duplikate wurden gefunden.", - "no_exif_info_available": "Keine Exif-Informationen vorhanden", - "no_explore_results_message": "Lade weitere Fotos hoch, um deine Sammlung zu vergrößern.", + "no_duplicates_found": "Es wurden keine Duplikate gefunden.", + "no_exif_info_available": "Keine EXIF-Informationen vorhanden", + "no_explore_results_message": "Lade weitere Fotos hoch, um deine Sammlung zu erkunden.", "no_favorites_message": "Füge Favoriten hinzu, um deine besten Bilder und Videos schnell zu finden", "no_libraries_message": "Eine externe Bibliothek erstellen, um deine Fotos und Videos anzusehen", "no_name": "Kein Name", @@ -899,25 +951,28 @@ "no_results_description": "Versuche es mit einem Synonym oder einem allgemeineren Stichwort", "no_shared_albums_message": "Erstelle ein Album, um Fotos und Videos mit Personen in deinem Netzwerk zu teilen", "not_in_any_album": "In keinem Album", - "note_apply_storage_label_to_previously_uploaded assets": "Hinweis: Um ein Storage-Label zu verwenden, starte den", + "note_apply_storage_label_to_previously_uploaded assets": "Hinweis: Um eine Speicherpfadbezeichnung anzuwenden, starte den", "note_unlimited_quota": "Hinweis: Verwende 0 für ein unlimitiertes Kontingent", "notes": "Notizen", "notification_toggle_setting_description": "E-Mail-Benachrichtigungen aktivieren", "notifications": "Benachrichtigungen", "notifications_setting_description": "Benachrichtigungen verwalten", "oauth": "OAuth", + "official_immich_resources": "Offizielle Immich Quellen", "offline": "Offline", "offline_paths": "Offline-Pfade", "offline_paths_description": "Diese Ergebnisse können auf das manuelle Löschen von Dateien zurückzuführen sein, die nicht Teil einer externen Bibliothek sind.", "ok": "Ok", "oldest_first": "Älteste zuerst", "onboarding": "Einstieg", + "onboarding_privacy_description": "Die folgenden (optionalen) Funktionen hängen von externen Diensten ab und können jederzeit in den Administrationseinstellungen deaktiviert werden.", "onboarding_theme_description": "Wähle ein Farbschema für deine Instanz aus. Du kannst dies später in deinen Einstellungen ändern.", "onboarding_welcome_description": "Lass uns deine Instanz mit einigen allgemeinen Einstellungen konfigurieren.", "onboarding_welcome_user": "Willkommen, {user}", "online": "Online", "only_favorites": "Nur Favoriten", "only_refreshes_modified_files": "Nur geänderte Dateien aktualisieren", + "open_in_map_view": "In Kartenansicht öffnen", "open_in_openstreetmap": "In OpenStreetMap öffnen", "open_the_search_filters": "Die Suchfilter öffnen", "options": "Optionen", @@ -925,7 +980,7 @@ "organize_your_library": "Organisiere deine Bibliothek", "original": "Original", "other": "Sonstiges", - "other_devices": "Sonstige Geräte", + "other_devices": "Andere Geräte", "other_variables": "Sonstige Variablen", "owned": "Eigenes", "owner": "Besitzer", @@ -952,13 +1007,14 @@ "pending": "Ausstehend", "people": "Personen", "people_edits_count": "{count, plural, one {# Person} other {# Personen}} bearbeitet", + "people_feature_description": "Fotos und Videos nach Personen gruppiert durchsuchen", "people_sidebar_description": "Eine Verknüpfung zu Personen in der Seitenleiste anzeigen", "perform_library_tasks": "", "permanent_deletion_warning": "Warnung vor endgültiger Löschung", "permanent_deletion_warning_setting_description": "Anzeige einer Warnung beim permanenten Löschen von Objekten", "permanently_delete": "Dauerhaft löschen", "permanently_delete_assets_count": "{count, plural, one {Datei} other {Dateien}} dauerhaft gelöscht", - "permanently_delete_assets_prompt": "Bist du sicher, dass {count, plural, one {diese Datei} other {diese # Dateien}} dauerhaft gelöscht werden soll? Dadurch werden diese auch aus deinen Alben entfernt.", + "permanently_delete_assets_prompt": "Bist du sicher, dass {count, plural, one {diese Datei} other {diese # Dateien}} dauerhaft gelöscht werden soll? Dadurch {count, plural, one {wird} other {werden}} diese auch aus deinen Alben entfernt.", "permanently_deleted_asset": "Dauerhaft gelöschtes Objekt", "permanently_deleted_assets": "{count, plural, one {# Objekt} other {# Objekte}} dauerhaft gelöscht", "permanently_deleted_assets_count": "{count, plural, one {# Datei} other {# Dateien}} dauerhaft gelöscht", @@ -969,13 +1025,13 @@ "photos_and_videos": "Fotos & Videos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", "photos_from_previous_years": "Fotos von vorherigen Jahren", - "pick_a_location": "Wählen einen Ort", + "pick_a_location": "Wähle einen Ort", "place": "Ort", "places": "Orte", "play": "Abspielen", "play_memories": "Erinnerungen abspielen", "play_motion_photo": "Bewegte Bilder abspielen", - "play_or_pause_video": "Video Abspielen oder Pausieren", + "play_or_pause_video": "Video abspielen oder pausieren", "point": "Hinweis", "port": "Port", "preset": "Voreinstellung", @@ -984,10 +1040,11 @@ "previous_memory": "Vorherige Erinnerung", "previous_or_next_photo": "Vorheriges oder nächstes Foto", "primary": "Primär", + "privacy": "Privatsphäre", "profile_image_of_user": "Profilbild von {user}", "profile_picture_set": "Profilbild gesetzt.", "public_album": "Öffentliches Album", - "public_share": "Öffentliche Teilung", + "public_share": "Öffentliche Freigabe", "purchase_account_info": "Unterstützer", "purchase_activated_subtitle": "Danke für die Unterstützung von Immich und Open-Source Software", "purchase_activated_time": "Aktiviert am {date, date}", @@ -1001,58 +1058,65 @@ "purchase_button_select": "Auswählen", "purchase_failed_activation": "Aktivieren fehlgeschlagen! Überprüfe bitte den Produktschlüssel in der E-Mail!", "purchase_individual_description_1": "Für eine Einzelperson", - "purchase_individual_description_2": "Unterstützer Status", + "purchase_individual_description_2": "Unterstützerstatus", "purchase_individual_title": "Einzelperson", "purchase_input_suggestion": "Besitzen Sie bereits einen Produktschlüssel? Bitte geben Sie diesen unten ein", - "purchase_license_subtitle": "Kaufe Immich um eine fortlaufende Entwicklung zu unterstützen", + "purchase_license_subtitle": "Kaufe Immich, um die fortlaufende Entwicklung zu unterstützen", "purchase_lifetime_description": "Lebenslange Gültigkeit", - "purchase_option_title": "KAUF OPTIONEN", - "purchase_panel_info_1": "Das Entwickeln von Immich ist aufwendig und nimmt viel Zeit in Anspruch, deshalb haben wir ein Team von Vollzeit-Entwickler*innen, welche ihr Bestes geben. Unser Ziel ist es, mit Open-Source Software und ethischen Unternehmenspraktiken eine nachhaltige Einkommensquelle für unsere Entwickler und ein privatsphäre-respektierendes Ökosystem für unsere Nutzenden zu schaffen. Wir wollen eine kompetitive Alternative zu ausbeuterischen Cloud-Diensten erschaffen.", - "purchase_panel_info_2": "Weil wir davon überzeugt sind keine Paywalls zu haben, wird dieser Kauf keine zusätzlichen Funktionen in Immich freischalten. Wir verlassen uns auf Nutzende wie dich, um Entwicklung von Immich zu unterstützen.", + "purchase_option_title": "KAUFOPTIONEN", + "purchase_panel_info_1": "Die Entwicklung von Immich erfordert viel Zeit und Mühe, und wir haben Vollzeit-Entwickler, die daran arbeiten es möglichst perfekt zu machen. Unser Ziel ist es, dass Open-Source-Software und moralische Geschäftsmethoden zu einer nachhaltigen Einkommensquelle für Entwickler werden und ein datenschutzfreundliches Ökosystem mit echten Alternativen zu ausbeuterischen Cloud-Diensten geschaffen wird.", + "purchase_panel_info_2": "Weil wir davon überzeugt sind keine Paywalls zu haben, wird dieser Kauf keine zusätzlichen Funktionen in Immich freischalten. Wir verlassen uns auf Nutzende wie dich, um die Entwicklung von Immich zu unterstützen.", "purchase_panel_title": "Das Projekt unterstützen", "purchase_per_server": "Pro Server", "purchase_per_user": "Pro Benutzer", "purchase_remove_product_key": "Produktschlüssel entfernen", "purchase_remove_product_key_prompt": "Sicher, dass der Produktschlüssel entfernt werden soll?", - "purchase_remove_server_product_key": "Server Produktschlüssel entfernen", - "purchase_remove_server_product_key_prompt": "Sicher, dass der Server Produktschlüssel entfernt werden soll?", + "purchase_remove_server_product_key": "Server-Produktschlüssel entfernen", + "purchase_remove_server_product_key_prompt": "Sicher, dass der Server-Produktschlüssel entfernt werden soll?", "purchase_server_description_1": "Für den gesamten Server", - "purchase_server_description_2": "Unterstützer Status", + "purchase_server_description_2": "Unterstützerstatus", "purchase_server_title": "Server", - "purchase_settings_server_activated": "Der Server Produktschlüssel wird durch den Administrator verwaltet", + "purchase_settings_server_activated": "Der Server-Produktschlüssel wird durch den Administrator verwaltet", "range": "Reichweite", + "rating": "Bewertung", + "rating_clear": "Bewertung löschen", + "rating_count": "{count, plural, one {# Stern} other {# Sterne}}", + "rating_description": "Stellt die EXIF-Bewertung im Informationsbereich dar", "raw": "RAW", "reaction_options": "Reaktionsmöglichkeiten", "read_changelog": "Changelog lesen", "reassign": "Neu zuweisen", - "reassigned_assets_to_existing_person": "{count, plural, one {# Datei} other {# Dateien}} wurden {name, select, null {einer vorhandenen Person} other {{name}}} zugewiesen", - "reassigned_assets_to_new_person": "{count, plural, one {# Datei} other {# Dateien}} wurden einer neuen Person zugewiesen", + "reassigned_assets_to_existing_person": "{count, plural, one {# Datei wurde} other {# Dateien wurden}} {name, select, null {einer vorhandenen Person} other {{name}}} zugewiesen", + "reassigned_assets_to_new_person": "{count, plural, one {# Datei wurde} other {# Dateien wurden}} einer neuen Person zugewiesen", "reassing_hint": "Markierte Dateien einer vorhandenen Person zuweisen", "recent": "Neuste", "recent_searches": "Letzte Suchen", "refresh": "Aktualisieren", - "refresh_encoded_videos": "Codierte Videos aktualisieren", + "refresh_encoded_videos": "Kodierte Videos aktualisieren", + "refresh_faces": "Gesichter aktualisieren", "refresh_metadata": "Metadaten aktualisieren", - "refresh_thumbnails": "Vorschaubilder aktualisieren", + "refresh_thumbnails": "Miniaturansichten aktualisieren", "refreshed": "Aktualisiert", - "refreshes_every_file": "Jede Datei aktualisieren", - "refreshing_encoded_video": "Codierte Videos werden aktualisiert", + "refreshes_every_file": "Alle bestehenden und neuen Dateien erneut einlesen", + "refreshing_encoded_video": "Kodierte Videos werden aktualisiert", + "refreshing_faces": "Gesichter werden aktualisiert", "refreshing_metadata": "Metadaten werden aktualisiert", - "regenerating_thumbnails": "Vorschaubilder werden neu erstellt", + "regenerating_thumbnails": "Miniaturansichten werden neu erstellt", "remove": "Entfernen", "remove_assets_album_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} aus dem Album entfernen willst?", "remove_assets_shared_link_confirmation": "Bist du sicher, dass du {count, plural, one {# Datei} other {# Dateien}} von diesem geteilten Link entfernen willst?", "remove_assets_title": "Dateien entfernen?", "remove_custom_date_range": "Benutzerdefinierten Datumsbereich entfernen", + "remove_deleted_assets": "Offline-Dateien entfernen", "remove_from_album": "Aus Album entfernen", "remove_from_favorites": "Aus Favoriten entfernen", - "remove_from_shared_link": "Aus geteilten Link entfernen", - "remove_offline_files": "Offline-Dateien entfernen", + "remove_from_shared_link": "Aus geteiltem Link entfernen", "remove_user": "Nutzer entfernen", "removed_api_key": "API-Schlüssel {name} wurde entfernt", "removed_from_archive": "Aus dem Archiv entfernt", - "removed_from_favorites": "Von Favoriten entfernt", - "removed_from_favorites_count": "{count, plural, other {#}} von Favoriten entfernt", + "removed_from_favorites": "Aus den Favoriten entfernt", + "removed_from_favorites_count": "{count, plural, other {#}} aus den Favoriten entfernt", + "removed_tagged_assets": "Tag von {count, plural, one {# Datei} other {# Dateien}} entfernt", "rename": "Umbenennen", "repair": "Reparatur", "repair_no_results_message": "Nicht auffindbare und fehlende Dateien werden hier angezeigt", @@ -1084,6 +1148,7 @@ "say_something": "Etwas sagen", "scan_all_libraries": "Alle Bibliotheken scannen", "scan_all_library_files": "Alle Bibliotheksdateien erneut scannen", + "scan_library": "Scannen", "scan_new_library_files": "Neue Bibliotheksdateien scannen", "scan_settings": "Scan-Einstellungen", "scanning_for_album": "Nach Alben scannen...", @@ -1099,9 +1164,12 @@ "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_settings": "Suche nach Einstellungen", "search_state": "Suche nach Bundesland / Provinz...", + "search_tags": "Sache nach Tags...", "search_timezone": "Suche nach Zeitzone...", "search_type": "Suche nach Typ", "search_your_photos": "Durchsuche deine Fotos", @@ -1119,18 +1187,18 @@ "select_library_owner": "Bibliotheksbesitzer auswählen", "select_new_face": "Neues Gesicht auswählen", "select_photos": "Fotos auswählen", - "select_trash_all": "Alle Löschen", + "select_trash_all": "Alle löschen", "selected": "Ausgewählt", "selected_count": "{count, plural, other {# ausgewählt}}", "send_message": "Nachricht senden", "send_welcome_email": "Begrüssungsmail senden", "server": "Server", - "server_offline": "Server Offline", - "server_online": "Server Online", + "server_offline": "Server offline", + "server_online": "Server online", "server_stats": "Server-Statistiken", "server_version": "Server-Version", "set": "Speichern", - "set_as_album_cover": "Als Albumcover gesetzt", + "set_as_album_cover": "Als Albumcover festlegen", "set_as_profile_picture": "Als Profilbild festlegen", "set_date_of_birth": "Geburtsdatum festlegen", "set_profile_picture": "Profilbild einstellen", @@ -1141,8 +1209,9 @@ "shared": "Geteilt", "shared_by": "Geteilt von", "shared_by_user": "Von {user} geteilt", - "shared_by_you": "Geteilt von dir", + "shared_by_you": "Von dir geteilt", "shared_from_partner": "Fotos von {partner}", + "shared_link_options": "Optionen für geteilten Link", "shared_links": "Geteilte Links", "shared_photos_and_videos_count": "{assetCount, plural, one {# geteiltes Foto oder Video.} other {# geteilte Fotos & Videos.}}", "shared_with_partner": "Geteilt mit {partner}", @@ -1151,13 +1220,14 @@ "sharing_sidebar_description": "Eine Verknüpfung zu Geteiltem in der Seitenleiste anzeigen", "shift_to_permanent_delete": "Drücke ⇧, um die Datei endgültig zu löschen", "show_album_options": "Album-Optionen anzeigen", + "show_albums": "Alben anzeigen", "show_all_people": "Alle Personen anzeigen", "show_and_hide_people": "Personen ein- & ausblenden", "show_file_location": "Dateispeicherort anzeigen", "show_gallery": "Galerie anzeigen", "show_hidden_people": "Ausgeblendete Personen anzeigen", "show_in_timeline": "In Zeitleiste anzeigen", - "show_in_timeline_setting_description": "Fotos und Videos dieses Benutzers in deiner Timeline anzeigen", + "show_in_timeline_setting_description": "Fotos und Videos dieses Benutzers in deiner Zeitleiste anzeigen", "show_keyboard_shortcuts": "Tastaturkürzel anzeigen", "show_metadata": "Metadaten anzeigen", "show_or_hide_info": "Informationen ein- oder ausblenden", @@ -1165,15 +1235,20 @@ "show_person_options": "Personen-Optionen anzeigen", "show_progress_bar": "Fortschrittsbalken anzeigen", "show_search_options": "Suchoptionen anzeigen", - "show_supporter_badge": "Unterstützer Abzeichen", - "show_supporter_badge_description": "Zeige Unterstützer Abzeichen", + "show_slideshow_transition": "Slideshow-Übergang anzeigen", + "show_supporter_badge": "Unterstützerabzeichen", + "show_supporter_badge_description": "Zeige Unterstützerabzeichen", "shuffle": "Durchmischen", + "sidebar": "Seitenleiste", + "sidebar_display_description": "Zeige einen Link zu der Ansicht in der Seitenleiste an", "sign_out": "Abmelden", "sign_up": "Registrieren", "size": "Größe", "skip_to_content": "Zum Inhalt springen", + "skip_to_folders": "Springe zu Ordnern", + "skip_to_tags": "Springe zu Tags", "slideshow": "Diashow", - "slideshow_settings": "Diashow Einstellungen", + "slideshow_settings": "Diashow-Einstellungen", "sort_albums_by": "Alben sortieren nach...", "sort_created": "Erstellungsdatum", "sort_items": "Anzahl der Einträge", @@ -1181,8 +1256,10 @@ "sort_oldest": "Ältestes Foto", "sort_recent": "Neustes Foto", "sort_title": "Titel", - "source": "Quelle", + "source": "Quellcode", "stack": "Stapel", + "stack_duplicates": "Duplikate stapeln", + "stack_select_one_photo": "Hauptfoto für den Stapel auswählen", "stack_selected_photos": "Ausgewählte Fotos stapeln", "stacked_assets_count": "{count, plural, one {# Datei} other {# Dateien}} gestapelt", "stacktrace": "Stacktrace", @@ -1194,50 +1271,66 @@ "stop_photo_sharing": "Deine Fotos nicht mehr teilen?", "stop_photo_sharing_description": "{partner} wird keinen Zugriff mehr auf deine Fotos haben.", "stop_sharing_photos_with_user": "Aufhören Fotos mit diesem Benutzer zu teilen", - "storage": "Speicher", + "storage": "Speicherplatz", "storage_label": "Speicherpfad", "storage_usage": "{used} von {available} verwendet", "submit": "Bestätigen", "suggestions": "Vorschläge", "sunrise_on_the_beach": "Sonnenaufgang am Strand", + "support": "Unterstützung", + "support_and_feedback": "Unterstützung & Feedback", + "support_third_party_description": "Deine Immich-Installation wurde von einem Drittanbieter zusammengestellt. Probleme, die bei dir auftreten, können durch dieses Paket verursacht werden. Bitte wende dich daher in erster Linie an diesen Anbieter, indem du die unten stehenden Links verwendest.", "swap_merge_direction": "Vertauschen der Zusammenführungsrichtung", "sync": "Synchronisieren", + "tag": "Tag", + "tag_assets": "Dateien taggen", + "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_updated": "Tag aktualisiert: {tag}", + "tagged_assets": "{count, plural, one {# Datei} other {# Dateien}} getagged", + "tags": "Tags", "template": "Vorlage", "theme": "Theme", "theme_selection": "Themenauswahl", "theme_selection_description": "Automatische Einstellung des Themes auf Hell oder Dunkel, je nach Systemeinstellung des Browsers", "they_will_be_merged_together": "Sie werden zusammengeführt", + "third_party_resources": "Drittanbieter-Quellen", "time_based_memories": "Zeitbasierte Erinnerungen", "timezone": "Zeitzone", "to_archive": "Archivieren", "to_change_password": "Passwort ändern", "to_favorite": "Zu Favoriten hinzufügen", "to_login": "Anmelden", - "to_trash": "Zum Papierkorb verschieben", + "to_parent": "Gehe zum Übergeordneten", + "to_root": "Zur Wurzel", + "to_trash": "In den Papierkorb verschieben", "toggle_settings": "Einstellungen umschalten", - "toggle_theme": "Theme umschalten", + "toggle_theme": "Dunkles Theme umschalten", "toggle_visibility": "Sichtbarkeit umschalten", "total_usage": "Gesamtnutzung", "trash": "Papierkorb", - "trash_all": "Alles im Papierkorb", + "trash_all": "Alle löschen", "trash_count": "Papierkorb {count, number}", "trash_delete_asset": "Datei löschen/in den Papierkorb verschieben", "trash_no_results_message": "Gelöschte Fotos und Videos werden hier angezeigt.", "trashed_items_will_be_permanently_deleted_after": "Gelöschte Objekte werden nach {days, plural, one {# Tag} other {# Tagen}} endgültig gelöscht.", "type": "Typ", - "unarchive": "Unarchivieren", + "unarchive": "Entarchivieren", "unarchived": "Unarchiviert", - "unarchived_count": "{count, plural, other {# Entarchiviert}}", + "unarchived_count": "{count, plural, other {# entarchiviert}}", "unfavorite": "Entfavorisieren", "unhide_person": "Person einblenden", "unknown": "Unbekannt", "unknown_album": "Unbekanntes Album", "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", "unnamed_album": "Unbenanntes Album", - "unnamed_share": "Unbenannte Teilung", + "unnamed_album_delete_confirmation": "Bist du sicher, dass du dieses Album löschen willst?", + "unnamed_share": "Unbenannte Freigabe", "unsaved_change": "Ungespeicherte Änderung", "unselect_all": "Alles abwählen", "unselect_all_duplicates": "Alle Duplikate abwählen", @@ -1249,7 +1342,7 @@ "updated_password": "Passwort aktualisiert", "upload": "Hochladen", "upload_concurrency": "Parallelität beim Hochladen", - "upload_errors": "Hochladen abgeschlossen mit {count, plural, one {# Fehler} other {# Fehlern}}, aktualisiere die Seite, um neu hochgeladene Dateien zu sehen.", + "upload_errors": "Hochladen mit {count, plural, one {# Fehler} other {# Fehlern}} abgeschlossen, aktualisiere die Seite, um neu hochgeladene Dateien zu sehen.", "upload_progress": "{remaining, number} verbleibend - {processed, number}/{total, number} verarbeitet", "upload_skipped_duplicates": "{count, plural, one {# doppelte Datei} other {# doppelte Dateien}} ausgelassen", "upload_status_duplicates": "Duplikate", @@ -1276,6 +1369,8 @@ "version": "Version", "version_announcement_closing": "Dein Freund, Alex", "version_announcement_message": "Hallo Freund, es gibt eine neue Version dieser Anwendung. Bitte nimm dir Zeit, die Versionshinweise zu lesen und stelle sicher, dass deine docker-compose.yml- und .env-Konfiguration auf dem neuesten Stand ist, um Fehlkonfigurationen zu vermeiden, insbesondere wenn du WatchTower oder ein anderes Verfahren verwendest, das deine Anwendung automatisch aktualisiert.", + "version_history": "Versionshistorie", + "version_history_item": "{version} am {date} installiert", "video": "Video", "video_hover_setting": "Videovorschau beim Hovern abspielen", "video_hover_setting_description": "Video-Miniaturansicht wiedergeben, wenn der Mauszeiger über dem Element verweilt. Auch wenn diese Funktion deaktiviert ist, kann die Wiedergabe gestartet werden, indem der Mauszeiger auf das Wiedergabesymbol bewegt wird.", @@ -1285,13 +1380,14 @@ "view_album": "Album anzeigen", "view_all": "Alles anzeigen", "view_all_users": "Alle Nutzer anzeigen", + "view_in_timeline": "In Zeitleiste anzeigen", "view_links": "Links anzeigen", "view_next_asset": "Nächste Datei anzeigen", "view_previous_asset": "Vorherige Datei anzeigen", "view_stack": "Stapel anzeigen", "viewer": "Zuschauer", "visibility_changed": "Sichtbarkeit für {count, plural, one {# Person} other {# Personen}} geändert", - "waiting": "Warte", + "waiting": "Wartend", "warning": "Warnung", "week": "Woche", "welcome": "Willkommen", diff --git a/i18n/el.json b/i18n/el.json new file mode 100644 index 0000000000..b29e95b9c4 --- /dev/null +++ b/i18n/el.json @@ -0,0 +1,653 @@ +{ + "about": "Σχετικά", + "account": "Λογαριασμός", + "account_settings": "Ρυθμίσεις Λογαριασμού", + "acknowledge": "Έλαβα γνώση", + "action": "Ενέργεια", + "actions": "Ενέργειες", + "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": "Προσθήκη σε κοινόχρηστο άλμπουμ", + "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": "Αυτό το στοιχείο εξωτερικής βιβλιοθήκης δε βρίσκεται πλέον στο δίσκο και έχει μεταφερθεί στα σκουπίδια. Εάν το αρχείο έχει μετακινηθεί εντός της βιβλιοθήκης, ελέγξτε το χρονοδιάγραμμα σας για το νέο αντίστοιχο στοιχείο. Για να επαναφέρετε αυτό το στοιχείο, βεβαιωθείτε ότι το παρακάτω μονοπάτι αρχείου είναι προσβάσιμο από το Immich και ότι μπορεί να σαρώσει τη βιβλιοθήκη.", + "authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας", + "authentication_settings_description": "Διαχείριση κωδικού πρόσβασης, OAuth και άλλες ρυθμίσεις ελέγχου ταυτότητας", + "authentication_settings_disable_all": "Είστε βέβαιοι ότι θέλετε να απενεργοποιήσετε όλες τις μεθόδους σύνδεσης; Η σύνδεση θα απενεργοποιηθεί πλήρως.", + "authentication_settings_reenable": "Για να επαναενεργοποιηθεί, χρησιμοποιήστε μία Server Command.", + "background_task_job": "Εργασίες Παρασκηνίου", + "check_all": "Έλεγχος Όλων", + "cleared_jobs": "Εκκαθάριση εργασιών για: {job}", + "config_set_by_file": "Η διαμόρφωση γίνεται προς το παρόν από ένα αρχείο config", + "confirm_delete_library": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη βιβλιοθήκη {library};", + "confirm_delete_library_assets": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή τη βιβλιοθήκη; Αυτό θα διαγράψει τα {count, plural, one {# contained asset} other {all # contained assets}} από το Immich και δεν μπορεί να αναιρεθεί. Τα αρχεία θα παραμείνουν στον δίσκο.", + "confirm_email_below": "Για επιβεβαίωση, πληκτρολογήστε \"{email}\" παρακάτω", + "confirm_reprocess_all_faces": "Είστε βέβαιοι ότι θέλετε να επεξεργαστείτε ξανά όλα τα πρόσωπα; Αυτό θα διαγράψει επίσης άτομα με όνομα.", + "confirm_user_password_reset": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τον κωδικό πρόσβασης του χρήστη {user};", + "create_job": "Δημιουργία εργασίας", + "disable_login": "Απενεργοποίηση σύνδεσης κατά την είσοδο", + "duplicate_detection_job_description": "Εκτελέστε τη εκμάθηση μηχανής σε στοιχεία για να εντοπίσετε παρόμοιες εικόνες. Βασίζεται στην Έξυπνη Αναζήτηση", + "exclusion_pattern_description": "Τα πρότυπα αποκλεισμού σας επιτρέπουν να αγνοείται αρχεία κκαι φακέλους όσο σαρώνεται η βιβλιοθήκη. Αυτό είναι χρήσιμο εάν εχετε φακέλους που περιέχουν αρχεία που δεν θέλετε να εισαγάγετε, όπως αρχεία RAW.", + "external_library_created_at": "Εξωτερική βιβλιοθήκη (δημιουργήθηκε {date})", + "external_library_management": "Διαχείριση Εξωτερικών Βιβλιοθηκών", + "face_detection": "Αναγνώριση προσώπου", + "face_detection_description": "Εντοπίστε τα πρόσωπα σε στοιχεία χρησιμοποιώντας μηχανική εκμάθηση. Για βίντεο, λαμβάνεται υπόψη μόνο η μικρογραφία. Η επιλογή \"Ανανέωση\" επεξεργάζεται εκ νέου όλα τα στοιχεία και η επιλογή \"Επαναφορά\", επιπλέον επαναφέρει ολα τα δεδομένα προσώπου. Η επιλογή \"Όσα Λείπουν\" προσθέτει στην ουρά στοιχεία που δεν έχουν υποστεί ακόμη επεξεργασία. Τα πρόσωπα που έχουν εντοπιστεί θα μπουν στην ουρά για την Αναγνώριση Προσώπου μετά την ολοκλήρωση της Ανίχνευσης Προσώπου, ομαδοποιώντας τα σε υπάρχοντα ή νέα άτομα.", + "facial_recognition_job_description": "Ομαδοποιήστε εντοπισμένα πρόσωπα σε άτομα. Αυτό το βήμα εκτελείται αφού ολοκληρωθεί η Ανίχνευση προσώπου. Η επιλογή \"Επαναφορά\" ομαδοποιεί εκ νέου όλα τα πρόσωπα. Η επιλογή \"Όσα Λείπουν\" ομαδοποιεί πρόσωπα που δεν έχουν αντιστοιχηθεί σε κάποιο άτομο.", + "failed_job_command": "Η Εντολή {command} απέτυχε για την εργασία: {job}", + "force_delete_user_warning": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό θα αφαιρέσει άμεσα το χρήστη και όλα τα στοιχεία. Αυτό δεν μπορεί να αναιρεθεί και τα αρχεία δεν μπορούν να ανακτηθούν.", + "forcing_refresh_library_files": "Επιβολή ανανέωσης όλων των αρχείων της βιβλιοθήκης", + "image_format": "Μορφή", + "image_format_description": "Η μορφή WebP παράγει μικρότερα αρχεία από τη μορφή JPEG, αλλά είναι πιο αργή στην κωδικοποίηση.", + "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_preview_description": "Μεσαίου μεγέθους εικόνες, χωρίς μεταδεδομένα, οι οποίες χρησιμοποιύνται όταν γίνεται θέαση ενός αντικειμένου και για μηχανική μάθηση", + "image_preview_format": "Μορφή προεπισκόπησης", + "image_preview_quality_description": "Ποιότητα προεπισκόπησης, απο 1-100. Μεγαλύτερες τιμές είναι καλύτερες, αλλά παράγουν μεγαλύτερα αρχεία που μπορεί να μειώσουν την ταχύτητα της εφαρμογής. Χαμηλές τιμές μπορεί να επηρεάσουν τη ποιότητα του machine learning.", + "image_preview_resolution": "Ανάλυση προεπισκόπησης", + "image_preview_resolution_description": "Χρησιμοποιείται κατά την προβολή μιας φωτογραφίας και για μηχανική εκμάθηση. Οι υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "image_preview_title": "Ρυθμίσεις προεπισκόπισης", + "image_quality": "Ποιότητα", + "image_quality_description": "Ποιότητα εικόνας από 1-100. Μεγαλύτερη τιμή σημαίνει καλύτερη ποιότητα, αλλά παράγει μεγαλύτερα αρχεία. Αυτή η επιλογή επηρεάζει τις εικόνες προεπισκόπησης και μικρογραφιών.", + "image_resolution": "Ανάλυση", + "image_resolution_description": "Υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "image_settings": "Ρυθμίσεις Εικόνας", + "image_settings_description": "Διαχείριση της ποιότητας και της ανάλυσης των εικόνων που δημιουργούνται", + "image_thumbnail_description": "Μικρό εικονίδιο χωρίς μεταδεδομένα, χρησιμοποιείται όταν γίνεται θέαση ομάδας φωτογραφιών, όπως η κύρια χρονογραμμή", + "image_thumbnail_format": "Μορφή μικρογραφίας", + "image_thumbnail_quality_description": "Ποιότητα μικρογραφίας, απο 1-100. Μεγαλύτερες τιμές είναι καλύτερες, αλλά παράγουν μεγαλύτερα αρχεία που μπορεί να μειώσουν την ταχύτητα της εφαρμογής.", + "image_thumbnail_resolution": "Ανάλυση μικρογραφίας", + "image_thumbnail_resolution_description": "Χρησιμοποιείται κατά την προβολή ομάδων φωτογραφιών (κύριο χρονολόγιο, προβολή άλμπουμ κλπ.). Υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "image_thumbnail_title": "Ρυθμίσεις μικρογραφίας", + "job_concurrency": "{job} συγχρονισμός", + "job_created": "Εργασία δημιουργήθηκε", + "job_not_concurrency_safe": "Αυτή η εργασία δεν είναι ασφαλής για ταυτόχρονη εκτέλεση.", + "job_settings": "Ρυθμίσεις Εργασιών", + "job_settings_description": "Διαχείριση ταυτόχρονων εργασιών", + "job_status": "Κατάσταση Εργασιών", + "jobs_delayed": "{jobCount, plural, one {# καθυστέρησε} other {# καθυστέρησαν}}", + "jobs_failed": "{jobCount, plural, one {# απέτυχε} other {# απέτυχαν}}", + "library_created": "Δημιουργήθηκε η βιβλιοθήκη: {library}", + "library_cron_expression": "Εκφράσεις Cron", + "library_cron_expression_description": "Ορισμός των διαστημάτων μεταξύ των σαρώσεων με χρήση cron μορφής. Για περισσότερες πληροφορίες παρακαλώ επισκεφθείτε το π.χ. Crontab Guru", + "library_cron_expression_presets": "Προκαθορισμένες εκφράσεις Cron", + "library_deleted": "Η βιβλιοθήκη διαγράφηκε", + "library_import_path_description": "Καθορίστε έναν φάκελο για εισαγωγή. Αυτός ο φάκελος, συμπεριλαμβανομένων των υποφακέλων του, θα σαρωθεί για εικόνες και βίντεο.", + "library_scanning": "Περιοδική Σάρωση", + "library_scanning_description": "Διαμόρφωση περιοδικής σάρωσης βιβλιοθήκης", + "library_scanning_enable_description": "Ενεργοποίηση περιοδικής σάρωσης βιβλιοθήκης", + "library_settings": "Εξωτερική Βιβλιοθήκη", + "library_settings_description": "Διαχείριση ρυθμίσεων εξωτερικής βιβλιοθήκης", + "library_tasks_description": "Εκτέλεση εργασιών βιβλιοθήκης", + "library_watching_enable_description": "Παρακολούθηση εξωτερικών βιβλιοθηκών για τροποποιήσεις αρχείων", + "library_watching_settings": "Παρακολούθηση βιβλιοθήκης (ΠΕΙΡΑΜΑΤΙΚΟ)", + "library_watching_settings_description": "Αυτόματη παρακολούθηση για τροποποιημένα αρχεία", + "logging_enable_description": "Ενεργοποίηση καταγραφής", + "logging_level_description": "Όταν είναι ενεργοποιημένο, τι επίπεδο καταγραφής να εφαρμοστεί.", + "logging_settings": "Καταγραφή", + "machine_learning_clip_model": "Μοντέλο CLIP", + "machine_learning_clip_model_description": "The name of a CLIP model listed here. Note that you must re-run the 'Smart Search' job for all images upon changing a model.", + "machine_learning_duplicate_detection": "Εντοπισμός Διπλότυπων", + "machine_learning_duplicate_detection_enabled": "Ενεργοποίηση εντοπισμού διπλότυπων", + "machine_learning_duplicate_detection_enabled_description": "Εάν απενεργοποιηθεί, θα υπάρξει και πάλι εκκαθάριση των ταυτόσημων στοιχείων.", + "machine_learning_duplicate_detection_setting_description": "Use CLIP embeddings to find likely duplicates", + "machine_learning_enabled": "Ενεργοποίηση μηχανικής εκμάθησης", + "machine_learning_enabled_description": "Εάν απενεργοποιηθεί, όλες οι λειτουργίες μηχανικής εκμάθησης θα απενεργοποιηθούν, ανεξάρτητα από τις παρακάτω ρυθμίσεις.", + "machine_learning_facial_recognition": "Αναγνώριση προσώπου", + "machine_learning_facial_recognition_description": "Εντοπισμός, αναγνώριση και ομαδοποίηση προσώπων σε εικόνες", + "machine_learning_facial_recognition_model": "Μοντέλο αναγνώρισης προσώπου", + "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_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_smart_search": "Έξυπνη Αναζήτηση", + "machine_learning_smart_search_description": "Αναζητήστε εικόνες σημασιολογικά χρησιμοποιώντας ενσωματώσεις CLIP", + "machine_learning_smart_search_enabled": "Ενεργοποίηση έξυπνης αναζήτησης", + "machine_learning_smart_search_enabled_description": "Αν απενεργοποιηθεί, οι εικόνες δεν θα κωδικοποιούνται για έξυπνη αναζήτηση.", + "machine_learning_url_description": "URL του διακομιστή μηχανικής εκμάθησης", + "manage_concurrency": "Διαχείριση ταυτόχρονη εκτέλεσης", + "manage_log_settings": "Διαχείριση ρυθμίσεων αρχείου καταγραφής", + "map_dark_style": "Σκούρο Θέμα", + "map_enable_description": "Ενεργοποίηση λειτουργιών χάρτη", + "map_gps_settings": "Ρυθμίσεις Χάρτη & GPS", + "map_gps_settings_description": "Διαχείριση Ρυθμίσεων Χάρτη & GPS (Αντίστροφη γεωκωδικοποίηση)", + "map_implications": "Η λειτουργία χάρτη βασίζεται σε εξωτερικές υπηρεσίες για τα πλακίδια (tiles.immich.cloud)", + "map_light_style": "Φωτεινό Θέμα", + "map_manage_reverse_geocoding_settings": "Διαχείριση ρυθμίσεων Αντίστροφης Γεωκωδικοποίησης", + "map_reverse_geocoding": "Αντίστροφη Γεωκωδικοποίηση", + "map_reverse_geocoding_enable_description": "Ενεργοποίηση Αντίστροφης Γεωκωδικοποίησης", + "map_reverse_geocoding_settings": "Ρυθμίσεις Αντίστροφης Γεωκωδικοποίησης", + "map_settings": "Χάρτης", + "map_settings_description": "Διαχείριση ρυθμίσεων χάρτη", + "map_style_description": "URL προς αρχείο θέματος του χάρτη style.json", + "metadata_extraction_job": "Εξαγωγή μεταδεδομένων", + "metadata_extraction_job_description": "Εξαγωγή μεταδεδομένων από κάθε αρχείο, όπως τοποθεσία, πρόσωπα και ανάλυση", + "metadata_faces_import_setting": "Ενεργοποίηση εισαγωγής προσώπων", + "metadata_faces_import_setting_description": "Εισαγωγή προσώπων από EXIF εικόνων και παρόμοια αρχεία ( sidecar files)", + "metadata_settings": "Ρυθμίσεις μεταδεδομένων", + "metadata_settings_description": "Διαχείρηση ρυθμίσεων μεταδεδομένων", + "migration_job": "Μεταφορά δεδομένων (Migration)", + "migration_job_description": "Μεταφορά των εικονιδίων για αρχεία και πρόσωπα στην πιο πρόσφατη δομή αρχείων", + "no_paths_added": "Δεν προστέθηκαν διαδρομές", + "no_pattern_added": "Δεν προστέθηκε μοτίβο", + "note_apply_storage_label_previous_assets": "Σημείωση: Για να εφαρμοστεί η Ετικέτα Αποθήκευσης σε στοιχεία που είχαν αναρτηθεί παλαιότερα, εκτέλεσε το", + "note_cannot_be_changed_later": "ΣΗΜΕΊΩΣΗ: Αυτό δεν μπορεί να τροποποιηθεί αργότερα!", + "note_unlimited_quota": "Σημείωση: Εισαγάγετε 0 για απεριόριστο όριο", + "notification_email_from_address": "Διεύθυνση αποστολέα", + "notification_email_from_address_description": "Διεύθυνση αποστολέα, πχ: \"Immich Photo Server \"", + "notification_email_host_description": "Πάροχος του email server (πχ smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Παράβλεψη των σφαλμάτων πιστοποίησης", + "notification_email_ignore_certificate_errors_description": "Παράβλεψη σφαλμάτων επικύρωσης της πιστοποίησης TLS (δεν προτείνεται)", + "notification_email_password_description": "Κωδικός για την αυθεντικοποίηση με τον server του email", + "notification_email_port_description": "Θύρα του email server (πχ 25, 465, ή 587)", + "notification_email_sent_test_email_button": "Αποστολή test email και αποθήκευση", + "notification_email_setting_description": "Ρυθμίσεις για την αποστολή ειδοποιήσεων μέσω email", + "notification_email_test_email": "Αποστολή test email", + "notification_email_test_email_failed": "Αποτυχία αποστολής test email, ελέγξτε τις ρυθμίσεις", + "notification_email_test_email_sent": "Ένα test email στάλθηκε στην διεύθυνση {email}. Παρακαλώ ελέγξτε τα εισερχόμενα σας.", + "notification_email_username_description": "Όνομα χρήστη για την αυθεντικοποίηση με τον server του email", + "notification_enable_email_notifications": "Ενεργοποίηση ειδοποιήσεων μέσω email", + "notification_settings": "Ρυθμίσεις ειδοποιήσεων", + "notification_settings_description": "Διαχείρηση ρυθμίσεων ειδοποιήσεων, συμπεριλαμβανομένου του email", + "oauth_auto_launch": "Αυτόματη εκκίνηση", + "oauth_auto_launch_description": "Αυτόματη εκκίνιση της υπηρεσίας OAuth με την πλοήγηση στην σελίδα σύνδεσης", + "oauth_auto_register": "Αυτόματη καταχώρηση", + "oauth_auto_register_description": "Αυτόματη καταχώρηση νέου χρήστη αφού συνδεθεί με OAuth", + "oauth_button_text": "Κείμενο κουμπιού", + "oauth_client_id": "Ταυτότητα πελάτη (Client)", + "oauth_client_secret": "Client Secret", + "oauth_enable_description": "Σύνδεση με OAuth", + "oauth_issuer_url": "Issuer URL", + "oauth_mobile_redirect_uri": "Mobile redirect URI", + "oauth_mobile_redirect_uri_override": "Mobile redirect URI override", + "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'", + "oauth_profile_signing_algorithm": "Αλγόριθμος σύνδεσης προφίλ", + "oauth_profile_signing_algorithm_description": "Αλγόριθμος που χρησιμοποιείται για την σύνδεση των χρηστών.", + "oauth_scope": "Scope", + "oauth_settings": "OAuth" + }, + "assets_restore_confirmation": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε όλα τα στοιχεία που βρίσκονται στον κάδο απορριμμάτων; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί! Λάβετε υπόψη ότι δεν θα είναι δυνατή η επαναφορά στοιχείων εκτός σύνδεσης.", + "assets_restored_count": "Έγινε επαναφορά {count, plural, one {# στοιχείου} other {# στοιχείων}}", + "assets_trashed_count": "Μετακιν. στον κάδο απορριμάτων {count, plural, one {# στοιχείο} other {# στοιχεία}}", + "assets_were_part_of_album_count": "{count, plural, one {Το στοιχείο ανήκει} other {Τα στοιχεία ανήκουν}} ήδη στο άλμπουμ", + "authorized_devices": "Εξουσιοδοτημένες Συσκευές", + "back": "Πίσω", + "backward": "Προς τα πίσω", + "birthdate_saved": "Η ημερομηνία γέννησης αποθηκεύτηκε επιτυχώς", + "birthdate_set_description": "Η ημερομηνία γέννησης χρησιμοποιείται για τον υπολογισμό της ηλικίας αυτού του ατόμου, τη χρονική στιγμή μιας φωτογραφίας.", + "blurred_background": "Θολό φόντο", + "dismiss_error": "Παράβλεψη σφάλματος", + "display_options": "Επιλογές εμφάνισης", + "display_original_photos": "Εμφάνιση πρωτότυπων φωτογραφιών", + "do_not_show_again": "Να μην εμφανιστεί ξανά αυτό το μήνυμα", + "done": "Έγινε", + "download": "Λήψη", + "download_settings": "Λήψη", + "duplicates": "Διπλότυπα", + "duplicates_description": "Επιλύστε κάθε ομάδα υποδεικνύοντας ποιες είναι διπλότυπες, εάν υπάρχουν", + "duration": "Διάρκεια", + "edit": "Επεξεργασία", + "edit_album": "Επεξεργασία άλμπουμ", + "edit_avatar": "Επεξεργασία άβαταρ", + "edit_date": "Επεξεργασία ημερομηνίας", + "edit_date_and_time": "Επεξεργασία ημερομηνίας και ώρας", + "edit_faces": "Επεξεργασία προσώπων", + "edit_import_path": "Επεξεργασία διαδρομής εισαγωγής", + "edit_import_paths": "Επεξεργασία Διαδρομών Εισαγωγής", + "edit_link": "Επεξεργασία συνδέσμου", + "edit_location": "Επεξεργασία τοποθεσίας", + "edit_name": "Επεξεργασία ονόματος", + "edit_people": "Επεξεργασία ατόμων", + "edit_title": "Επεξεργασία Τίτλου", + "edit_user": "Επεξεργασία χρήστη", + "email": "Email", + "empty_trash": "Άδειασμα κάδου απορριμμάτων", + "enable": "Ενεργοποίηση", + "enabled": "Ενεργοποιημένο", + "error": "Σφάλμα", + "error_loading_image": "Σφάλμα κατά τη φόρτωση της εικόνας", + "error_title": "Σφάλμα - Κάτι πήγε στραβά", + "errors": { + "cannot_navigate_next_asset": "Δεν είναι δυνατή η πλοήγηση στο επόμενο στοιχείο", + "cannot_navigate_previous_asset": "Δεν είναι δυνατή η πλοήγηση στο προηγούμενο στοιχείο", + "cant_apply_changes": "Δεν είναι δυνατή η εφαρμογή αλλαγών" + }, + "jobs": "Εργασίες", + "keep": "Διατήρηση", + "keep_all": "Διατήρηση Όλων", + "keyboard_shortcuts": "Συντομεύσεις πληκτρολογίου", + "language": "Γλώσσα", + "language_setting_description": "Επιλέξτε τη γλώσσα που προτιμάτε", + "latest_version": "Τελευταία Έκδοση", + "latitude": "Γεωγραφικό πλάτος", + "level": "Επίπεδο", + "library": "Βιβλιοθήκη", + "library_options": "Επιλογές βιβλιοθήκης", + "link_options": "Επιλογές συνδέσμου", + "list": "Λίστα", + "loading": "Φόρτωση", + "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": "Ενεργοποιήστε την αυτόματη επανάληψη ενός βίντεο στο πρόγραμμα προβολής λεπτομερειών.", + "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", + "map": "Χάρτης", + "map_marker_for_images": "Δείκτης χάρτη για εικόνες που τραβήχτηκαν σε {city}, {country}", + "map_marker_with_image": "Χάρτης δείκτη με εικόνα", + "map_settings": "Ρυθμίσεις χάρτη", + "matches": "Αντιστοιχίες", + "media_type": "Τύπος πολυμέσου", + "memories": "Αναμνήσεις", + "memories_setting_description": "Διαχειριστείτε τι θα εμφανίζεται στις αναμνήσεις σας", + "memory": "Ανάμνηση", + "menu": "Μενού", + "merge": "Συγχώνευση", + "merge_people": "Συγχώνευση ατόμων", + "merge_people_limit": "Μπορείτε να συγχωνεύσετε μόνο έως και 5 πρόσωπα τη φορά", + "merge_people_prompt": "Θέλετε να συγχωνεύσετε αυτά τα άτομα; Αυτή η ενέργεια είναι μη αναστρέψιμη.", + "merge_people_successfully": "Τα άτομα συγχωνεύθηκαν με επιτυχία", + "merged_people_count": "Έγινε συγχώνευση {count, plural, one {# ατόμου} other {# ατόμων}}", + "minimize": "Ελαχιστοποίηση", + "minute": "Λεπτό", + "missing": "Όσα Λείπουν", + "model": "Μοντέλο", + "month": "Μήνας", + "more": "Περισσότερα", + "moved_to_trash": "Μετακινήθηκε στον κάδο απορριμμάτων", + "my_albums": "Τα άλμπουμ μου", + "name": "Όνομα", + "name_or_nickname": "Όνομα ή ψευδώνυμο", + "never": "Ποτέ", + "new_album": "Νέο Άλμπουμ", + "new_api_key": "Νέο API Key", + "new_password": "Νέος κωδικός πρόσβασης", + "new_person": "Νέο άτομο", + "new_user_created": "Ο νέος χρήστης δημιουργήθηκε", + "new_version_available": "ΔΙΑΘΕΣΙΜΗ ΝΕΑ ΕΚΔΟΣΗ", + "newest_first": "Τα νεότερα πρώτα", + "next": "Επόμενο", + "next_memory": "Επόμενη ανάμνηση", + "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_favorites_message": "Προσθέστε αγαπημένα για να βρείτε γρήγορα τις καλύτερες φωτογραφίες και τα βίντεό σας", + "no_libraries_message": "Δημιουργήστε μια εξωτερική βιβλιοθήκη για να προβάλετε τις φωτογραφίες και τα βίντεό σας", + "no_name": "Χωρίς Όνομα", + "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": "Ενεργοποίηση ειδοποιήσεων μέσω email", + "notifications": "Ειδοποιήσεις", + "notifications_setting_description": "Διαχείριση ειδοποιήσεων", + "oauth": "OAuth", + "offline": "Εκτός σύνδεσης", + "offline_paths": "Διαδρομές εκτός σύνδεσης", + "offline_paths_description": "Αυτά τα αποτελέσματα μπορεί να οφείλονται στη μη αυτόματη διαγραφή αρχείων που δεν αποτελούν μέρος μιας εξωτερικής βιβλιοθήκης.", + "ok": "Έγινε", + "oldest_first": "Τα παλαιότερα πρώτα", + "onboarding_theme_description": "Επιλέξτε ένα θέμα χρώματος για το προφίλ σας. Μπορείτε να το αλλάξετε αργότερα στις ρυθμίσεις σας.", + "onboarding_welcome_description": "Ας ρυθμίσουμε το προφίλ σας με ορισμένες κοινές ρυθμίσεις.", + "onboarding_welcome_user": "Καλωσόρισες, {user}", + "online": "Σε σύνδεση", + "only_favorites": "Μόνο αγαπημένα", + "only_refreshes_modified_files": "Ανανεώνει μόνο τροποποιημένα αρχεία", + "open_in_map_view": "Άνοιγμα σε προβολή χάρτη", + "open_in_openstreetmap": "Άνοιγμα στο OpenStreetMap", + "open_the_search_filters": "Ανοίξτε τα φίλτρα αναζήτησης", + "options": "Επιλογές", + "or": "ή", + "organize_your_library": "Οργανώστε τη βιβλιοθήκη σας", + "original": "πρωτότυπο", + "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": "Επιτυχής επαναφορά κωδικού πρόσβασης", + "path": "Διαδρομή", + "pattern": "Μοτίβο", + "pause": "Πάυση", + "pause_memories": "Παύση αναμνήσεων", + "paused": "Σε Πάυση", + "pending": "Εκκρεμεί", + "people": "Άτομα", + "people_edits_count": "Έγινε επεξεργασία {count, plural, one {# ατόμου} other {# ατόμων}}", + "people_sidebar_description": "Εμφάνιση Ατόμων στην πλαϊνή γραμμή", + "permanent_deletion_warning": "Προειδοποίηση οριστικής διαγραφής", + "permanent_deletion_warning_setting_description": "Εμφάνιση προειδοποίησης κατά την οριστική διαγραφή στοιχείων", + "permanently_delete": "Οριστική διαγραφή", + "permanently_delete_assets_count": "Οριστική διαγραφή {count, plural, one {στοιχείου} other {στοιχείων}}", + "permanently_delete_assets_prompt": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά {count, plural, one {αυτό το στοιχείο;} other {αυτά τα # στοιχεία;}} Αυτό θα {count, plural, one {το} other {τα}} αφαιρέσει επίσης από τα άλμπουμ στα οποία {count, plural, one {ανήκει} other {ανήκουν}} .", + "permanently_deleted_asset": "Οριστικά διαγραμμένο στοιχείο", + "permanently_deleted_assets_count": "Οριστική διαγραφή {count, plural, one {# στοιχείου} other {# στοιχείων}}", + "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": "Τοποθεσία", + "places": "Τοποθεσίες", + "play": "Αναπαραγωγή", + "play_memories": "Αναπαραγωγή αναμνήσεων", + "play_motion_photo": "Αναπαραγωγή Κινούμενης Φωτογραφίας", + "play_or_pause_video": "Αναπαραγωγή ή παύση βίντεο", + "preview": "Προεπισκόπηση", + "previous": "Προηγούμενο", + "previous_memory": "Προηγούμενη ανάμνηση", + "previous_or_next_photo": "Προηγούμενη ή επόμενη φωτογραφία", + "profile_image_of_user": "Εικόνα προφίλ του χρήστη {user}", + "profile_picture_set": "Ορισμός εικόνας προφίλ.", + "public_album": "Δημόσιο άλμπουμ", + "public_share": "Δημόσια Κοινή Χρήση", + "purchase_account_info": "Υποστηρικτής", + "purchase_activated_subtitle": "Σας ευχαριστούμε για την υποστήριξη του Immich και λογισμικών ανοιχτού κώδικα", + "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": "Η ενεργοποίηση απέτυχε! Ελέγξτε το email σας για το σωστό κλειδί προϊόντος!", + "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 απαιτεί πολύ χρόνο και προσπάθεια, και έχουμε μηχανικούς πλήρους απασχόλησης που εργάζονται σε αυτό για να το κάνουμε όσο το δυνατόν καλύτερο. Η αποστολή μας είναι το λογισμικό ανοιχτού κώδικα και οι ηθικές επιχειρηματικές πρακτικές να γίνουν βιώσιμη πηγή εισοδήματος για προγραμματιστές και να δημιουργήσουμε ένα οικοσύστημα που σέβεται το απόρρητο, με πραγματικές εναλλακτικές λύσεις στις υπηρεσίες cloud που παρουσιάζουν συμπεριφορές εκμετάλλευσης.", + "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": "Η διαχείριση του κλειδιού προϊόντος του διακομιστή γίνεται από τον διαχειριστή", + "reaction_options": "Επιλογές αντίδρασης", + "read_changelog": "Διαβάστε το Αρχείο Καταγραφής Αλλαγών", + "refresh_faces": "Ανανέωση προσώπων", + "refreshing_faces": "Ανανεώνονται πρόσωπα", + "restore_user": "Επαναφορά χρήστη", + "retry_upload": "Επανάληψη ανεβάσματος", + "review_duplicates": "Προβολή διπλότυπων", + "save": "Αποθήκευση", + "saved_profile": "Αποθηκευμένο προφίλ", + "saved_settings": "Αποθηκευμένες ρυθμίσεις", + "say_something": "Πείτε κάτι", + "scan_all_libraries": "Σάρωση Όλων των Βιβλιοθηκών", + "scan_new_library_files": "Σάρωση Νέων Αρχείων Βιβλιοθήκης", + "scan_settings": "Ρυθμίσεις Σάρωσης", + "scanning_for_album": "Σάρωση για άλμπουμ...", + "search": "Αναζήτηση", + "search_albums": "Αναζήτηση άλμπουμ", + "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_no_people": "Κανένα άτομο", + "search_no_people_named": "Κανένα άτομο με όνομα \"{name}\"", + "search_people": "Αναζήτηση ατόμων", + "search_places": "Αναζήτηση τοποθεσιών", + "search_state": "Αναζήτηση νομού...", + "search_timezone": "Αναζήτηση ζώνης ώρας...", + "search_type": "Τύπος αναζήτησης", + "search_your_photos": "Αναζήτηση φωτογραφιών", + "second": "Δευτερόλεπτο", + "see_all_people": "Προβολή όλων των ατόμων", + "select_album_cover": "Επιλέξτε εξώφυλλο άλμπουμ", + "select_all": "Επιλογή όλων", + "select_all_duplicates": "Επιλογή όλων των διπλότυπων", + "select_avatar_color": "Επιλέξτε χρώμα avatar", + "select_face": "Επιλογή προσώπου", + "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": "Αποστολή email καλωσορίσματος", + "server_offline": "Διακομιστής Εκτός Σύνδεσης", + "server_online": "Διακομιστής Σε Σύνδεση", + "server_stats": "Στατιστικά Διακομιστή", + "server_version": "Έκδοση Διακομιστή", + "set": "Ορισμός", + "set_as_album_cover": "Ορισμός ως εξώφυλλο άλμπουμ", + "set_as_profile_picture": "Ορισμός ως εικόνα προφίλ", + "set_date_of_birth": "Ορισμός ημερομηνίας γέννησης", + "set_profile_picture": "Ορισμός εικόνας προφίλ", + "settings": "Ρυθμίσεις", + "settings_saved": "Οι ρυθμίσεις αποθηκεύτηκαν", + "share": "Κοινοποίηση", + "shared": "Σε κοινή χρήση", + "shared_by": "Σε κοινή χρήση από", + "shared_by_user": "Σε κοινή χρήση από {user}", + "shared_by_you": "Σε κοινή χρήση από εσάς", + "shared_from_partner": "Φωτογραφίες από {partner}", + "shared_links": "Κοινόχρηστοι σύνδεσμοι", + "shared_photos_and_videos_count": "{assetCount, plural, other {# κοινόχρηστες φωτογραφίες & βίντεο.}}", + "shared_with_partner": "Σε κοινή χρήση με {partner}", + "sharing": "Κοινοποίηση", + "sharing_enter_password": "Εισαγάγετε τον κωδικό πρόσβασης για να δείτε αυτήν τη σελίδα.", + "sharing_sidebar_description": "Εμφανίστε έναν σύνδεσμο για Κοινή χρήση στην πλαϊνή γραμμή", + "shift_to_permanent_delete": "πατήστε ⇧ για οριστική διαγραφή στοιχείου", + "show_album_options": "Εμφάνιση επιλογών άλμπουμ", + "show_all_people": "Προβολή όλων των ατόμων", + "show_and_hide_people": "Εμφάνιση & απόκρυψη ατόμων", + "show_file_location": "Εμφάνιση θέσης αρχείου", + "show_gallery": "Εμφάνιση γκαλερί", + "show_hidden_people": "Εμφάνιση κρυμμένων ατόμων", + "show_in_timeline": "Εμφάνιση στο χρονολόγιο", + "show_in_timeline_setting_description": "Εμφάνιση φωτογραφιών και βίντεο από αυτόν τον χρήστη στο χρονολόγιό σας", + "show_keyboard_shortcuts": "Εμφάνιση συντομεύσεων πληκτρολογίου", + "show_metadata": "Εμφάνιση μεταδεδομένων", + "show_or_hide_info": "Εμφάνιση ή απόκρυψη πληροφοριών", + "show_password": "Εμφάνιση κωδικού", + "show_person_options": "Εμφάνιση επιλογών ατόμου", + "show_progress_bar": "Εμφάνιση γραμμής προόδου", + "show_search_options": "Εμφάνιση επιλογών αναζήτησης", + "show_supporter_badge": "Σήμα υποστηρικτή", + "show_supporter_badge_description": "Εμφάνιση σήματος υποστηρικτή", + "shuffle": "Ανάμειξη", + "sign_out": "Αποσύνδεση", + "sign_up": "Εγγραφή", + "size": "Μέγεθος", + "skip_to_content": "Μετάβαση στο περιεχόμενο", + "slideshow": "Παρουσίαση", + "slideshow_settings": "Ρυθμίσεις παρουσίασης", + "sort_albums_by": "Ταξινόμηση άλμπουμ κατά...", + "sort_created": "Ημερομηνία Δημιουργίας", + "sort_items": "Αριθμός αντικειμένων", + "sort_modified": "Ημερομηνία τροποποίησης", + "sort_oldest": "Η πιο παλιά φωτογραφία", + "sort_recent": "Η πιο πρόσφατη φωτογραφία", + "sort_title": "Τίτλος", + "source": "Πηγή", + "start": "Έναρξη", + "start_date": "Από", + "state": "Νομός", + "status": "Κατάσταση", + "stop_motion_photo": "Διέκοψε την Φωτογραφία Κίνησης", + "stop_photo_sharing": "Διακοπή κοινής χρήσης των φωτογραφιών σας;", + "stop_photo_sharing_description": "Ο χρήστης {partner} δεν θα έχει πλέον πρόσβαση στις φωτογραφίες σας.", + "stop_sharing_photos_with_user": "Διακοπή κοινής χρήσης των φωτογραφιών σας με αυτό το χρήστη", + "storage": "Χώρος αποθήκευσης", + "storage_label": "Ετικέτα αποθήκευσης", + "storage_usage": "{used} από {available} σε χρήση", + "submit": "Υποβολή", + "suggestions": "Προτάσεις", + "sunrise_on_the_beach": "Ηλιοβασίλεμα στην παραλία", + "support": "Υποστήριξη", + "support_and_feedback": "Υποστήριξη & Σχόλια", + "swap_merge_direction": "Εναλλαγή κατεύθυνσης συγχώνευσης", + "sync": "Συγχρονισμός", + "tag": "Ετικέτα", + "tag_created": "Δημιουργήθηκε ετικέτα: {tag}", + "tag_updated": "Ενημερώθηκε η ετικέτα: {tag}", + "template": "Πρότυπο", + "theme": "Θέμα", + "theme_selection": "Επιλογή θέματος", + "theme_selection_description": "Ρυθμίστε αυτόματα το θέμα σε ανοιχτό ή σκούρο με βάση τις προτιμήσεις συστήματος του προγράμματος περιήγησής σας", + "they_will_be_merged_together": "Θα συγχωνευθούν μαζί", + "time_based_memories": "Μνήμες βασισμένες στο χρόνο", + "timezone": "Ζώνη ώρας", + "to_archive": "Αρχειοθέτηση", + "to_change_password": "Αλλαγή κωδικού πρόσβασης", + "to_favorite": "Αγαπημένο", + "to_login": "Είσοδος", + "to_trash": "Κάδος απορριμμάτων", + "toggle_settings": "Εναλλαγή ρυθμίσεων", + "toggle_theme": "Εναλλαγή θέματος", + "total_usage": "Συνολική χρήση", + "trash": "Κάδος απορριμμάτων", + "trash_all": "Διαγραφή Όλων", + "trash_count": "Διαγραφή {count, number}", + "trash_delete_asset": "Διαγραφή/Οριστ. Διαγραφή Αντικειμένου", + "trash_no_results_message": "Οι φωτογραφίες και τα βίντεο που βρίσκονται στον κάδο απορριμμάτων θα εμφανίζονται εδώ.", + "trashed_items_will_be_permanently_deleted_after": "Τα στοιχεία που βρίσκονται στον κάδο απορριμμάτων θα διαγραφούν οριστικά μετά από {days, plural, one {# ημέρα} other {# ημέρες}}.", + "unarchive": "Αναίρεση αρχειοθέτησης", + "unarchived_count": "{count, plural, other {Αρχειοθετήσεις αναιρέθηκαν #}}", + "unfavorite": "Αφαίρεση από τα αγαπημένα", + "unhide_person": "Αναίρεση απόκρυψης ατόμου", + "unknown": "Άγνωστο", + "unknown_year": "Άγνωστο Έτος", + "unlimited": "Απεριόριστο", + "unlink_oauth": "Αποσύνδεση OAuth", + "unlinked_oauth_account": "Ο λογαριασμός OAuth αποσυνδέθηκε", + "unnamed_album": "Ανώνυμο Άλμπουμ", + "unnamed_share": "Ανώνυμη Κοινή Χρήση", + "unsaved_change": "Μη αποθηκευμένη αλλαγή", + "unselect_all": "Αποεπιλογή όλων", + "unselect_all_duplicates": "Αποεπιλογή όλων των διπλότυπων", + "untracked_files": "Μη παρακολουθούμενα αρχεία", + "untracked_files_decription": "Αυτά τα αρχεία δεν παρακολουθούνται από την εφαρμογή. Μπορεί να είναι αποτελέσματα αποτυχημένων μετακινήσεων, αποτυχημένες μεταφορτώσεις ή εναπομείναντα λόγω σφάλματος", + "updated_password": "Ο κωδικός πρόσβασης ενημερώθηκε", + "upload": "Μεταφόρτωση", + "upload_errors": "Η μεταφόρτωση ολοκληρώθηκε με {count, plural, one {# σφάλμα} other {# σφάλματα}}, ανανεώστε τη σελίδα για να δείτε νέα στοιχεία μεταφόρτωσης.", + "upload_progress": "Απομένουν {remaining, number} - Ολοκληρώθηκαν {processed, number}/{total, number}", + "upload_skipped_duplicates": "Παραλείφθηκαν {count, plural, one {# διπλότυπο στοιχείο} other {# διπλότυπα στοιχεία}}", + "upload_status_duplicates": "Διπλότυπα", + "upload_status_errors": "Σφάλματα", + "upload_status_uploaded": "Μεταφορτώθηκαν", + "upload_success": "Η μεταφόρτωση ολοκληρώθηκε, ανανεώστε τη σελίδα για να δείτε τα νέα αντικείμενα.", + "url": "URL", + "usage": "Χρήση", + "use_custom_date_range": "Χρήση προσαρμοσμένου εύρους ημερομηνιών", + "user": "Χρήστης", + "user_id": "ID Χρήστη", + "user_liked": "Στο χρήστη {user} αρέσει {type, select, photo {αυτή η φωτογραφία} video {αυτό το βίντεο} asset {αυτό το αντικείμενο} other {it}}", + "user_purchase_settings": "Αγορά", + "user_purchase_settings_description": "Διαχείριση Αγοράς", + "user_role_set": "Ορισμός {user} ως {role}", + "username": "Όνομα Χρήστη", + "users": "Χρήστες", + "utilities": "Βοηθητικά προγράμματα", + "validate": "Επικύρωση", + "variables": "Μεταβλητές", + "version": "Έκδοση", + "version_announcement_closing": "Ο φίλος σου, Alex", + "version_announcement_message": "Γεια σου φίλε, υπάρχει μια νέα έκδοση της εφαρμογής, αφιέρωσε λίγο χρόνο για να επισκεφθείς την τοποθεσία release notes και να βεβαιωθείς ότι τα docker-compose.yml, και .env είναι ενημερωμένα για την αποτροπή τυχόν εσφαλμένων διαμορφώσεων, ειδικά εάν χρησιμοποιείτε το WatchTower ή οποιονδήποτε μηχανισμό που χειρίζεται την αυτόματη ενημέρωση της εφαρμογής σας.", + "version_history_item": "Εγκαταστάθηκε {version} στις {date}", + "video": "Βίντεο", + "video_hover_setting": "Προεπισκόπηση βίντεο με το δείκτη του ποντικιού", + "video_hover_setting_description": "Προεπισκόπηση βίντεο όταν το ποντίκι βρίσκεται πάνω από το στοιχείο. Ακόμη και όταν είναι απενεργοποιημένη, η αναπαραγωγή μπορεί να ξεκινήσει τοποθετώντας το δείκτη του ποντικιού πάνω από το εικονίδιο αναπαραγωγής.", + "videos": "Βίντεο", + "videos_count": "{count, plural, one {# Βίντεο} other {# Βίντεο}}", + "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": "Ζουμ Εικόνας" +} diff --git a/web/src/lib/i18n/en.json b/i18n/en.json similarity index 89% rename from web/src/lib/i18n/en.json rename to i18n/en.json index 6e0887cee9..d607e088b3 100644 --- a/web/src/lib/i18n/en.json +++ b/i18n/en.json @@ -28,11 +28,17 @@ "added_to_favorites_count": "Added {count, number} to favorites", "admin": { "add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".", + "asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.", "authentication_settings": "Authentication Settings", "authentication_settings_description": "Manage password, OAuth, and other authentication settings", "authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.", "authentication_settings_reenable": "To re-enable, use a Server Command.", "background_task_job": "Background Tasks", + "backup_database": "Backup Database", + "backup_database_enable_description": "Enable database backups", + "backup_keep_last_amount": "Amount of previous backups to keep", + "backup_settings": "Backup Settings", + "backup_settings_description": "Manage database backup settings", "check_all": "Check All", "cleared_jobs": "Cleared jobs for: {job}", "config_set_by_file": "Config is currently set by a config file", @@ -41,33 +47,40 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "create_job": "Create job", + "cron_expression": "Cron expression", + "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru", + "cron_expression_presets": "Cron expression presets", "disable_login": "Disable login", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", "external_library_created_at": "External library (created on {date})", "external_library_management": "External Library Management", "face_detection": "Face detection", - "face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"All\" (re-)processes all assets. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.", - "facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"All\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.", + "face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.", + "facial_recognition_job_description": "Group detected faces into people. This step runs after Face Detection is complete. \"Reset\" (re-)clusters all faces. \"Missing\" queues faces that don't have a person assigned.", "failed_job_command": "Command {command} failed for job: {job}", "force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.", "forcing_refresh_library_files": "Forcing refresh of all library files", + "image_format": "Format", "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.", "image_prefer_embedded_preview": "Prefer embedded preview", "image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.", "image_prefer_wide_gamut": "Prefer wide gamut", "image_prefer_wide_gamut_setting_description": "Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.", - "image_preview_format": "Preview format", - "image_preview_resolution": "Preview resolution", - "image_preview_resolution_description": "Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", + "image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning", + "image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.", + "image_preview_title": "Preview Settings", "image_quality": "Quality", - "image_quality_description": "Image quality from 1-100. Higher is better for quality but produces larger files, this option affects the Preview and Thumbnail images.", + "image_resolution": "Resolution", + "image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.", "image_settings": "Image Settings", "image_settings_description": "Manage the quality and resolution of generated images", - "image_thumbnail_format": "Thumbnail format", - "image_thumbnail_resolution": "Thumbnail resolution", - "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", + "image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline", + "image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.", + "image_thumbnail_title": "Thumbnail Settings", "job_concurrency": "{job} concurrency", + "job_created": "Job created", "job_not_concurrency_safe": "This job is not concurrency-safe.", "job_settings": "Job Settings", "job_settings_description": "Manage job concurrency", @@ -75,9 +88,6 @@ "jobs_delayed": "{jobCount, plural, other {# delayed}}", "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Created library: {library}", - "library_cron_expression": "Cron expression", - "library_cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru", - "library_cron_expression_presets": "Cron expression presets", "library_deleted": "Library deleted", "library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.", "library_scanning": "Periodic Scanning", @@ -127,16 +137,21 @@ "map_enable_description": "Enable map features", "map_gps_settings": "Map & GPS Settings", "map_gps_settings_description": "Manage Map & GPS (Reverse Geocoding) Settings", + "map_implications": "The map feature relies on an external tile service (tiles.immich.cloud)", "map_light_style": "Light style", "map_manage_reverse_geocoding_settings": "Manage Reverse Geocoding settings", "map_reverse_geocoding": "Reverse Geocoding", "map_reverse_geocoding_enable_description": "Enable reverse geocoding", "map_reverse_geocoding_settings": "Reverse Geocoding Settings", - "map_settings": "Map Settings", + "map_settings": "Map", "map_settings_description": "Manage map settings", "map_style_description": "URL to a style.json map theme", "metadata_extraction_job": "Extract metadata", - "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS and resolution", + "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS, faces and resolution", + "metadata_faces_import_setting": "Enable face import", + "metadata_faces_import_setting_description": "Import faces from image EXIF data and sidecar files", + "metadata_settings": "Metadata Settings", + "metadata_settings_description": "Manage metadata settings", "migration_job": "Migration", "migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure", "no_paths_added": "No paths added", @@ -145,7 +160,7 @@ "note_cannot_be_changed_later": "NOTE: This cannot be changed later!", "note_unlimited_quota": "Note: Enter 0 for unlimited quota", "notification_email_from_address": "From address", - "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", + "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignore certificate errors", "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)", @@ -171,7 +186,7 @@ "oauth_issuer_url": "Issuer URL", "oauth_mobile_redirect_uri": "Mobile redirect URI", "oauth_mobile_redirect_uri_override": "Mobile redirect URI override", - "oauth_mobile_redirect_uri_override_description": "Enable when 'app.immich:/' is an invalid redirect URI.", + "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'", "oauth_profile_signing_algorithm": "Profile signing algorithm", "oauth_profile_signing_algorithm_description": "Algorithm used to sign the user profile.", "oauth_scope": "Scope", @@ -191,19 +206,19 @@ "password_settings": "Password Login", "password_settings_description": "Manage password login settings", "paths_validated_successfully": "All paths validated successfully", + "person_cleanup_job": "Person cleanup", "quota_size_gib": "Quota Size (GiB)", "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.", - "removing_offline_files": "Removing Offline Files", "repair_all": "Repair All", "repair_matched_items": "Matched {count, plural, one {# item} other {# items}}", "repaired_items": "Repaired {count, plural, one {# item} other {# items}}", "require_password_change_on_login": "Require user to change password on first login", "reset_settings_to_default": "Reset settings to default", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", - "scanning_library_for_changed_files": "Scanning library for changed files", - "scanning_library_for_new_files": "Scanning library for new files", + "scanning_library": "Scanning library", + "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)://", @@ -231,6 +246,7 @@ "storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_user_label": "{label} is the user's Storage Label", "system_settings": "System Settings", + "tag_cleanup_job": "Tag cleanup", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -263,7 +279,7 @@ "transcoding_hardware_acceleration": "Hardware Acceleration", "transcoding_hardware_acceleration_description": "Experimental; much faster, but will have lower quality at the same bitrate", "transcoding_hardware_decoding": "Hardware decoding", - "transcoding_hardware_decoding_setting_description": "Applies only to NVENC, QSV and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos.", + "transcoding_hardware_decoding_setting_description": "Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos.", "transcoding_hevc_codec": "HEVC codec", "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.", @@ -275,7 +291,7 @@ "transcoding_preferred_hardware_device": "Preferred hardware device", "transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`.", + "transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.", "transcoding_reference_frames": "Reference frames", "transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.", "transcoding_required_description": "Only videos not in an accepted format", @@ -304,6 +320,7 @@ "trash_settings_description": "Manage trash settings", "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", "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.", @@ -317,7 +334,8 @@ "user_settings": "User Settings", "user_settings_description": "Manage user settings", "user_successfully_removed": "User {email} has been successfully removed.", - "version_check_enabled_description": "Enable periodic requests to GitHub to check for new releases", + "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", "version_check_settings_description": "Enable/disable the new version notification", "video_conversion_job": "Transcode videos", @@ -333,7 +351,8 @@ "album_added": "Album added", "album_added_notification_setting_description": "Receive an email notification when you are added to a shared album", "album_cover_updated": "Album cover updated", - "album_delete_confirmation": "Are you sure you want to delete the album {album}?\nIf this album is shared, other users will not be able to access it anymore.", + "album_delete_confirmation": "Are you sure you want to delete the album {album}?", + "album_delete_confirmation_description": "If this album is shared, other users will not be able to access it anymore.", "album_info_updated": "Album info updated", "album_leave": "Leave album?", "album_leave_confirmation": "Are you sure you want to leave {album}?", @@ -357,6 +376,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", + "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.", "api_key_empty": "Your API Key name shouldn't be empty", @@ -365,7 +385,7 @@ "appears_in": "Appears in", "archive": "Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", - "archive_size": "Archive Size", + "archive_size": "Archive size", "archive_size_description": "Configure the archive size for downloads (in GiB)", "archived_count": "{count, plural, other {Archived #}}", "are_these_the_same_person": "Are these the same person?", @@ -376,9 +396,10 @@ "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", "asset_hashing": "Hashing...", - "asset_offline": "Asset offline", - "asset_offline_description": "This asset is offline. Immich can not access its file location. Please ensure the asset is available and then rescan the library.", + "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...", "assets": "Assets", @@ -389,7 +410,7 @@ "assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash", "assets_permanently_deleted_count": "Permanently deleted {count, plural, one {# asset} other {# assets}}", "assets_removed_count": "Removed {count, plural, one {# asset} other {# assets}}", - "assets_restore_confirmation": "Are you sure you want to restore all your trashed assets? You cannot undo this action!", + "assets_restore_confirmation": "Are you sure you want to restore all your trashed assets? You cannot undo this action! Note that any offline assets cannot be restored this way.", "assets_restored_count": "Restored {count, plural, one {# asset} other {# assets}}", "assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album", @@ -400,6 +421,7 @@ "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", + "bugs_and_feature_requests": "Bugs & Feature Requests", "build": "Build", "build_image": "Build Image", "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!", @@ -432,9 +454,11 @@ "clear_all_recent_searches": "Clear all recent searches", "clear_message": "Clear message", "clear_value": "Clear value", + "clockwise": "Сlockwise", "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", + "color": "Color", "color_theme": "Color theme", "comment_deleted": "Comment deleted", "comment_options": "Comment options", @@ -468,6 +492,8 @@ "create_new_person": "Create new person", "create_new_person_hint": "Assign selected assets to a new person", "create_new_user": "Create new user", + "create_tag": "Create tag", + "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", "created": "Created", "current_device": "Current device", @@ -488,16 +514,20 @@ "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_key": "Delete key", - "delete_library": "Delete library", + "delete_library": "Delete Library", "delete_link": "Delete link", "delete_shared_link": "Delete shared link", + "delete_tag": "Delete tag", + "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?", "delete_user": "Delete user", "deleted_shared_link": "Deleted shared link", + "deletes_missing_assets": "Deletes assets missing from disk", "description": "Description", "details": "Details", "direction": "Direction", "disabled": "Disabled", "disallow_edits": "Disallow edits", + "discord": "Discord", "discover": "Discover", "dismiss_all_errors": "Dismiss all errors", "dismiss_error": "Dismiss error", @@ -506,8 +536,11 @@ "display_original_photos": "Display original photos", "display_original_photos_setting_description": "Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds.", "do_not_show_again": "Do not show this message again", + "documentation": "Documentation", "done": "Done", "download": "Download", + "download_include_embedded_motion_videos": "Embedded videos", + "download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file", "download_settings": "Download", "download_settings_description": "Manage settings related to asset download", "downloading": "Downloading", @@ -530,9 +563,15 @@ "edit_location": "Edit location", "edit_name": "Edit name", "edit_people": "Edit people", + "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", + "editor": "Editor", + "editor_close_without_save_prompt": "The changes will not be saved", + "editor_close_without_save_title": "Close editor?", + "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Email", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", @@ -618,6 +657,7 @@ "unable_to_get_comments_number": "Unable to get number of comments", "unable_to_get_shared_link": "Failed to get shared link", "unable_to_hide_person": "Unable to hide person", + "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", "unable_to_load_album": "Unable to load album", "unable_to_load_asset_activity": "Unable to load asset activity", @@ -633,8 +673,8 @@ "unable_to_remove_album_users": "Unable to remove users from album", "unable_to_remove_api_key": "Unable to remove API Key", "unable_to_remove_assets_from_shared_link": "Unable to remove assets from shared link", + "unable_to_remove_deleted_assets": "Unable to remove offline files", "unable_to_remove_library": "Unable to remove library", - "unable_to_remove_offline_files": "Unable to remove offline files", "unable_to_remove_partner": "Unable to remove partner", "unable_to_remove_reaction": "Unable to remove reaction", "unable_to_repair_items": "Unable to repair items", @@ -656,6 +696,7 @@ "unable_to_submit_job": "Unable to submit job", "unable_to_trash_asset": "Unable to trash asset", "unable_to_unlink_account": "Unable to unlink account", + "unable_to_unlink_motion_video": "Unable to unlink motion video", "unable_to_update_album_cover": "Unable to update album cover", "unable_to_update_album_info": "Unable to update album info", "unable_to_update_library": "Unable to update library", @@ -672,6 +713,7 @@ "expired": "Expired", "expires_date": "Expires {date}", "explore": "Explore", + "explorer": "Explorer", "export": "Export", "export_as_json": "Export as JSON", "extension": "Extension", @@ -682,6 +724,8 @@ "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", "feature_photo_updated": "Feature photo updated", + "features": "Features", + "features_setting_description": "Manage the app features", "file_name": "File name", "file_name_or_extension": "File name or extension", "filename": "Filename", @@ -689,14 +733,14 @@ "filter_people": "Filter people", "find_them_fast": "Find them fast by name with search", "fix_incorrect_match": "Fix incorrect match", - "force_re-scan_library_files": "Force Re-scan All Library Files", + "folders": "Folders", + "folders_feature_description": "Browsing the folder view for the photos and videos on the file system", "forward": "Forward", "general": "General", "get_help": "Get Help", "getting_started": "Getting Started", "go_back": "Go back", "go_to_search": "Go to search", - "go_to_share_page": "Go to share page", "group_albums_by": "Group albums by...", "group_no": "No grouping", "group_owner": "Group by owner", @@ -758,6 +802,7 @@ "library_options": "Library options", "light": "Light", "like_deleted": "Like deleted", + "link_motion_video": "Link motion video", "link_options": "Link options", "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", @@ -776,6 +821,7 @@ "look": "Look", "loop_videos": "Loop videos", "loop_videos_description": "Enable to automatically loop a video in the detail viewer.", + "main_branch_warning": "You’re using a development version; we strongly recommend using a release version!", "make": "Make", "manage_shared_links": "Manage shared links", "manage_sharing_with_partners": "Manage sharing with partners", @@ -845,18 +891,20 @@ "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", + "official_immich_resources": "Official Immich Resources", "offline": "Offline", "offline_paths": "Offline paths", "offline_paths_description": "These results may be due to manual deletion of files that are not part of an external library.", "ok": "Ok", "oldest_first": "Oldest first", "onboarding": "Onboarding", + "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in the administration settings.", "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.", "onboarding_welcome_description": "Let's get your instance set up with some common settings.", "onboarding_welcome_user": "Welcome, {user}", "online": "Online", "only_favorites": "Only favorites", - "only_refreshes_modified_files": "Only refreshes modified files", + "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", "options": "Options", @@ -891,6 +939,7 @@ "pending": "Pending", "people": "People", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", + "people_feature_description": "Browsing photos and videos grouped by people", "people_sidebar_description": "Display a link to People in the sidebar", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", @@ -920,6 +969,7 @@ "previous_memory": "Previous memory", "previous_or_next_photo": "Previous or next photo", "primary": "Primary", + "privacy": "Privacy", "profile_image_of_user": "Profile image of {user}", "profile_picture_set": "Profile picture set.", "public_album": "Public album", @@ -956,6 +1006,10 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "rating": "Star rating", + "rating_clear": "Clear rating", + "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "reassign": "Reassign", @@ -966,11 +1020,13 @@ "recent_searches": "Recent searches", "refresh": "Refresh", "refresh_encoded_videos": "Refresh encoded videos", + "refresh_faces": "Refresh faces", "refresh_metadata": "Refresh metadata", "refresh_thumbnails": "Refresh thumbnails", "refreshed": "Refreshed", - "refreshes_every_file": "Refreshes every file", + "refreshes_every_file": "Re-reads all existing and new files", "refreshing_encoded_video": "Refreshing encoded video", + "refreshing_faces": "Refreshing faces", "refreshing_metadata": "Refreshing metadata", "regenerating_thumbnails": "Regenerating thumbnails", "remove": "Remove", @@ -978,15 +1034,16 @@ "remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?", "remove_assets_title": "Remove assets?", "remove_custom_date_range": "Remove custom date range", + "remove_deleted_assets": "Remove Deleted Assets", "remove_from_album": "Remove from album", "remove_from_favorites": "Remove from favorites", "remove_from_shared_link": "Remove from shared link", - "remove_offline_files": "Remove Offline Files", "remove_user": "Remove user", "removed_api_key": "Removed API Key: {name}", "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", + "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", @@ -1016,8 +1073,7 @@ "saved_settings": "Saved settings", "say_something": "Say something", "scan_all_libraries": "Scan All Libraries", - "scan_all_library_files": "Re-scan All Library Files", - "scan_new_library_files": "Scan New Library Files", + "scan_library": "Scan", "scan_settings": "Scan Settings", "scanning_for_album": "Scanning for album...", "search": "Search", @@ -1032,9 +1088,12 @@ "search_for_existing_person": "Search for existing person", "search_no_people": "No people", "search_no_people_named": "No people named \"{name}\"", + "search_options": "Search options", "search_people": "Search people", "search_places": "Search places", + "search_settings": "Search settings", "search_state": "Search state...", + "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", @@ -1075,6 +1134,7 @@ "shared_by_user": "Shared by {user}", "shared_by_you": "Shared by you", "shared_from_partner": "Photos from {partner}", + "shared_link_options": "Shared link options", "shared_links": "Shared links", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", "shared_with_partner": "Shared with {partner}", @@ -1083,6 +1143,7 @@ "sharing_sidebar_description": "Display a link to Sharing in the sidebar", "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", + "show_albums": "Show albums", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", "show_file_location": "Show file location", @@ -1097,13 +1158,18 @@ "show_person_options": "Show person options", "show_progress_bar": "Show Progress Bar", "show_search_options": "Show search options", + "show_slideshow_transition": "Show slideshow transition", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", "shuffle": "Shuffle", + "sidebar": "Sidebar", + "sidebar_display_description": "Display a link to the view in the sidebar", "sign_out": "Sign Out", "sign_up": "Sign up", "size": "Size", "skip_to_content": "Skip to content", + "skip_to_folders": "Skip to folders", + "skip_to_tags": "Skip to tags", "slideshow": "Slideshow", "slideshow_settings": "Slideshow settings", "sort_albums_by": "Sort albums by...", @@ -1115,6 +1181,8 @@ "sort_title": "Title", "source": "Source", "stack": "Stack", + "stack_duplicates": "Stack duplicates", + "stack_select_one_photo": "Select one main photo for the stack", "stack_selected_photos": "Stack selected photos", "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}", "stacktrace": "Stacktrace", @@ -1132,22 +1200,35 @@ "submit": "Submit", "suggestions": "Suggestions", "sunrise_on_the_beach": "Sunrise on the beach", + "support": "Support", + "support_and_feedback": "Support & Feedback", + "support_third_party_description": "Your Immich installation was packaged by a third-party. Issues you experience may be caused by that package, so please raise issues with them in the first instance using the links below.", "swap_merge_direction": "Swap merge direction", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Tag assets", + "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_updated": "Updated tag: {tag}", + "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", + "tags": "Tags", "template": "Template", "theme": "Theme", "theme_selection": "Theme selection", "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference", "they_will_be_merged_together": "They will be merged together", + "third_party_resources": "Third-Party Resources", "time_based_memories": "Time-based memories", "timezone": "Timezone", "to_archive": "Archive", "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", + "to_parent": "Go to parent", "to_trash": "Trash", "toggle_settings": "Toggle settings", - "toggle_theme": "Toggle theme", + "toggle_theme": "Toggle dark theme", "total_usage": "Total usage", "trash": "Trash", "trash_all": "Trash All", @@ -1163,9 +1244,11 @@ "unknown": "Unknown", "unknown_year": "Unknown Year", "unlimited": "Unlimited", + "unlink_motion_video": "Unlink motion video", "unlink_oauth": "Unlink OAuth", "unlinked_oauth_account": "Unlinked OAuth account", "unnamed_album": "Unnamed Album", + "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?", "unnamed_share": "Unnamed Share", "unsaved_change": "Unsaved change", "unselect_all": "Unselect all", @@ -1203,6 +1286,8 @@ "version": "Version", "version_announcement_closing": "Your friend, Alex", "version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the release notes and ensure your docker-compose.yml, and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.", + "version_history": "Version History", + "version_history_item": "Installed {version} on {date}", "video": "Video", "video_hover_setting": "Play video thumbnail on hover", "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", @@ -1212,6 +1297,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_in_timeline": "View in timeline", "view_links": "View links", "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", @@ -1221,7 +1307,7 @@ "warning": "Warning", "week": "Week", "welcome": "Welcome", - "welcome_to_immich": "Welcome to immich", + "welcome_to_immich": "Welcome to Immich", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", diff --git a/web/src/lib/i18n/es.json b/i18n/es.json similarity index 82% rename from web/src/lib/i18n/es.json rename to i18n/es.json index c7abc16758..c58888a570 100644 --- a/web/src/lib/i18n/es.json +++ b/i18n/es.json @@ -2,80 +2,92 @@ "about": "Acerca de", "account": "Cuenta", "account_settings": "Ajustes de la cuenta", - "acknowledge": "Acuerdo", + "acknowledge": "De acuerdo", "action": "Acción", "actions": "Acciones", "active": "Activo", "activity": "Actividad", - "activity_changed": "Actividad es {enabled, select, true {enabled} other {disabled}}", - "add": "Añadir", - "add_a_description": "Añadir una descripción", - "add_a_location": "Añadir una ubicación", - "add_a_name": "Añadir un nombre", - "add_a_title": "Añadir un título", - "add_exclusion_pattern": "Añadir patrón de exclusión", - "add_import_path": "Añadir ruta de importación", - "add_location": "Añadir ubicación", - "add_more_users": "Añadir más usuarios", - "add_partner": "Añadir invitado", - "add_path": "Añadir ruta", - "add_photos": "Añadir fotos", - "add_to": "Añadir a...", - "add_to_album": "Añadir a un álbum", - "add_to_shared_album": "Añadir a un álbum compartido", - "added_to_archive": "Archivar", - "added_to_favorites": "Añadido a favoritos", - "added_to_favorites_count": "Añadido {count, number} a favoritos", + "activity_changed": "La actividad está {enabled, select, true {activada} other {desactivada}}", + "add": "Agregar", + "add_a_description": "Agregar descripción", + "add_a_location": "Agregar ubicación", + "add_a_name": "Agregar nombre", + "add_a_title": "Agregar título", + "add_exclusion_pattern": "Agregar patrón de exclusión", + "add_import_path": "Agregar ruta de importación", + "add_location": "Agregar ubicación", + "add_more_users": "Agregar más usuarios", + "add_partner": "Agregar invitado", + "add_path": "Agregar ruta", + "add_photos": "Agregar fotos", + "add_to": "Agregar a...", + "add_to_album": "Agregar a un álbum", + "add_to_shared_album": "Agregar a un álbum compartido", + "added_to_archive": "Archivado", + "added_to_favorites": "Agregado a favoritos", + "added_to_favorites_count": "Agregado {count, number} a favoritos", "admin": { - "add_exclusion_pattern_description": "Añade patrones de exclusión. Puedes utilizar los caracteres *, ** y ? (globbing). Para ignorar los archivos en cualquier ruta llamada \"Raw\", utiliza \"**/Raw/**\". Para ignorar todos los archivos que terminan en \".tif\", utiliza \"**/*.tif\". Para ignorar una ruta desde la raíz, utiliza \"/carpeta/a/ignorar/**\".", - "authentication_settings": "Configuración de Autenticación", - "authentication_settings_description": "Gestionar clave, Oauth y otros configuraciones de autenticación", - "authentication_settings_disable_all": "¿Estás seguro de que deseas desactivar todos los métodos de inicio de sesión? Se desactivará el inicio de sesión.", - "authentication_settings_reenable": "Para volver a habilitar, utilice un Comando del servidor .", + "add_exclusion_pattern_description": "Agrega patrones de exclusión. Puedes utilizar los caracteres *, ** y ? (globbing). Para ignorar todos los archivos en cualquier directorio llamado \"Raw\", utiliza \"**/Raw/**\". Para ignorar todos los archivos que terminan en \".tif\", utiliza \"**/*.tif\". Para ignorar una ruta absoluta, utiliza \"/carpeta/a/ignorar/**\".", + "asset_offline_description": "Este recurso externo de la biblioteca ya no se encuentra en el disco y se ha movido a la papelera. Si el archivo se movió dentro de la biblioteca, comprueba la línea de tiempo para el nuevo recurso correspondiente. Para restaurar este recurso, asegúrate de que Immich puede acceder a la siguiente ruta de archivo y escanear la biblioteca.", + "authentication_settings": "Parámetros de autenticación", + "authentication_settings_description": "Gestionar contraseñas, OAuth y otros parámetros de autenticación", + "authentication_settings_disable_all": "¿Está seguro de que deseas desactivar todos los métodos de inicio de sesión? El inicio de sesión se desactivará por completo.", + "authentication_settings_reenable": "Para volver a activarlo, utiliza un Comando del servidor .", "background_task_job": "Tareas en segundo plano", - "check_all": "Comprobar Todo", - "cleared_jobs": "Trabajos realizados para: {job}", - "config_set_by_file": "La configuración está fijada actualmente en base a un archivo", + "check_all": "Verificar todo", + "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}?", "confirm_delete_library_assets": "¿Estás seguro de que quieras eliminar esta biblioteca? Esto eliminará los {count, plural, one {# contained asset} other {all # contained assets}} elementos en Immich y no puede deshacerse. Los archivos permanecerán en tu almacenamiento.", - "confirm_email_below": "Para confirmar, escribe \"{email}\" debajo", - "confirm_reprocess_all_faces": "¿Estás seguro de que quieres volver a procesar todas las caras? Esto también eliminará las personas a las que le hayas asignado nombre.", - "confirm_user_password_reset": "¿Estás seguro de que quieres resetear la contraseña de {user}?", + "confirm_email_below": "Para confirmar, escribe \"{email}\" a continuación", + "confirm_reprocess_all_faces": "¿Estás seguro de que deseas reprocesar todas las caras? Esto borrará a todas las personas que nombraste.", + "confirm_user_password_reset": "¿Estás seguro de que quieres restablecer la contraseña de {user}?", + "create_job": "Crear trabajo", "crontab_guru": "Crontab Guru", "disable_login": "Deshabilitar inicio de sesión", "disabled": "Deshabilitado", - "duplicate_detection_job_description": "Lanza el aprendizaje automático para detectar imágenes similares. Necesita que esté activa la Búsqueda Inteligente", - "exclusion_pattern_description": "Los patrones de exclusión te permiten ignorar archivos y carpetas al escanear tu biblioteca. Esto es útil hay carpetas que contienen archivos que no quieres importar (por ejemplo los ficheros RAW).", - "external_library_created_at": "Biblioteca externa (creado el {date})", - "external_library_management": "Gestión de Biblioteca Externa", - "face_detection": "Detección de caras", - "face_detection_description": "Detecta las caras usando aprendizaje automático. Para los vídeos sólo se tiene en cuenta la imagen de previsualización. \"Todo\" implica volver a procesar todos los elementos. \"Missing\" pone en la cola los elementos que aún no han sido procesados. Las caras detectadas serán añadidas a la cola para ser procesadas posteriormente mediante Reconocimiento Facial y agrupadas en las personas que ya existan o en nuevas personas detectadas.", - "facial_recognition_job_description": "Agrupa las caras detectadas en las personas. Este paso se lanza tras las Detección de Caras. \"All\" reagrupa todas las caras. \"Pendiente\" añade a la colas aquellas caras que no fueron asignadas a ninguna persona.", + "duplicate_detection_job_description": "Ejecuta aprendizaje automático sobre los activos para detectar imágenes similares. Se basa en la búsqueda inteligente", + "exclusion_pattern_description": "Los patrones de exclusión te permiten ignorar archivos y carpetas al escanear tu biblioteca. Esto es útil si tienes carpetas que contienen archivos que no deseas importar, como archivos RAW.", + "external_library_created_at": "Biblioteca externa (creada el {date})", + "external_library_management": "Gestión de bibliotecas externas", + "face_detection": "Detección de rostros", + "face_detection_description": "Detectar las caras en los activos mediante aprendizaje automático. En el caso de los vídeos, solo se tiene en cuenta la miniatura. \"Actualizar\" (re)procesar todos los activos. \"Restablecer\" borra además todos los datos de caras actuales. \"Falta\" pone en cola los activos que aún no se han procesado. Los rostros detectados se pondrán en cola para el reconocimiento facial una vez finalizada la detección facial, agrupándolos en personas existentes o nuevas.", + "facial_recognition_job_description": "Agrupa los rostros detectados en personas. Este paso se ejecuta una vez finalizada la detección de caras. \"Restablecer\" (re)agrupa todas las caras. \"Falta\" pone en cola los rostros que no tienen asignada una persona.", "failed_job_command": "El comando {command} ha fallado para la tarea: {job}", "force_delete_user_warning": "CUIDADO: Esta acción eliminará inmediatamente el usuario y los elementos. Esta accion no se puede deshacer y los archivos no pueden ser recuperados.", - "forcing_refresh_library_files": "Forzar actualización de todos los archivos en las bibliotecas", + "forcing_refresh_library_files": "Forzar la recarga de todos los archivos de la biblioteca", + "image_format": "Formato", "image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificar.", "image_prefer_embedded_preview": "Preferir vista previa incrustada", "image_prefer_embedded_preview_setting_description": "Usar vistas previas incrustadas en fotos RAW como entrada para el procesamiento de imágenes cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.", "image_prefer_wide_gamut": "Preferir gama amplia", "image_prefer_wide_gamut_setting_description": "Usar \"Display P3\" para las miniaturas. Esto preserva mejor la vivacidad de las imágenes con espacios de color amplios, pero las imágenes pueden aparecer de manera diferente en dispositivos antiguos con una versión antigua del navegador. Las imágenes sRGB se mantienen como sRGB para evitar cambios de color.", + "image_preview_description": "Imagen de tamaño mediano con metadatos eliminados, utilizada al visualizar un solo activo y para aprendizaje automático", "image_preview_format": "Formato de previsualización", + "image_preview_quality_description": "Calidad de vista previa de 1 a 100. Cuanto más alta sea la calidad, mejor, pero genera archivos más grandes y puede reducir la capacidad de respuesta de la aplicación. Establecer un valor bajo puede afectar la calidad del aprendizaje automático.", "image_preview_resolution": "Resolución de previsualización", "image_preview_resolution_description": "Se utiliza al ver una sola foto y para el aprendizaje automático. Las resoluciones más altas pueden preservar más detalles, pero tardan más en codificarse, tienen tamaños de archivo más grandes y pueden reducir la capacidad de respuesta de la aplicación.", + "image_preview_title": "Ajustes de la vista previa", "image_quality": "Calidad", "image_quality_description": "Calidad de imagen de 1 a 100. Un valor más alto mejora la calidad pero genera archivos más grandes.", + "image_resolution": "Resolución", + "image_resolution_description": "Las resoluciones más altas pueden conservar más detalles, pero requieren más tiempo para codificar, tienen tamaños de archivo más grandes y pueden afectar la capacidad de respuesta de la aplicación.", "image_settings": "Ajustes de imagen", "image_settings_description": "Administrar la calidad y resolución de las imágenes generadas", + "image_thumbnail_description": "Miniatura pequeña con metadatos eliminados, que se utiliza al visualizar grupos de fotos como la línea de tiempo principal", "image_thumbnail_format": "Formato de las miniaturas", + "image_thumbnail_quality_description": "Calidad de miniatura de 1 a 100. Cuanto más alta, mejor, pero genera archivos más grandes y puede reducir la capacidad de respuesta de la aplicación.", "image_thumbnail_resolution": "Resolución de las miniaturas", "image_thumbnail_resolution_description": "Se utiliza para ver grupos de fotos (cronología, vista de álbum, etc.). Las resoluciones más altas pueden conservar más detalles, pero tardan más en codificarse, tienen archivos de mayor tamaño y pueden reducir la reactividad de la aplicación.", + "image_thumbnail_title": "Ajustes de las miniaturas", "job_concurrency": "{job}: Procesos simultáneos", + "job_created": "Trabajo creado", "job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.", "job_settings": "Configuración tareas", "job_settings_description": "Administrar tareas simultáneas", - "job_status": "Estado de la Tarea", - "jobs_delayed": "{jobCount, plural, other {# delayed}}", - "jobs_failed": "{jobCount, plural, other {# failed}}", + "job_status": "Estado de la tarea", + "jobs_delayed": "{jobCount, plural, one {# retrasado} other {# retrasados}}", + "jobs_failed": "{jobCount, plural, one {# fallido} other {# fallidos}}", "library_created": "La biblioteca ha sido creada: {library}", "library_cron_expression": "Expresión cron", "library_cron_expression_description": "Establece el intervalo de escaneo utilizando el formato cron. Para más información puede consultar, por ejemplo, Crontab Guru", @@ -85,7 +97,7 @@ "library_scanning": "Escaneado periódico", "library_scanning_description": "Configura el escaneo periódico de la biblioteca", "library_scanning_enable_description": "Activar el escaneo periódico de la biblioteca", - "library_settings": "Biblioteca Externa", + "library_settings": "Biblioteca externa", "library_settings_description": "Administrar configuración biblioteca externa", "library_tasks_description": "Realizar tareas de biblioteca", "library_watching_enable_description": "Ver las bibliotecas externas para detectar cambios en los archivos", @@ -129,16 +141,21 @@ "map_enable_description": "Habilitar características del mapa", "map_gps_settings": "Configuración de mapas y GPS", "map_gps_settings_description": "Administrar la configuración de mapas y GPS (geocodificación inversa)", + "map_implications": "La función de mapa depende de un servicio externo de mosaicos (tiles.immich.cloud)", "map_light_style": "Estilo claro", "map_manage_reverse_geocoding_settings": "Gestionar los ajustes de la geocodificación inversa", "map_reverse_geocoding": "Geocodificación inversa", "map_reverse_geocoding_enable_description": "Activar geocodificación inversa", "map_reverse_geocoding_settings": "Ajustes Geocodificación Inversa", - "map_settings": "Configuración del mapa", + "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)", "metadata_extraction_job": "Extracción de metadatos", - "metadata_extraction_job_description": "Extrae información de metadatos de cada elemento, como GPS y resolución", + "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", + "metadata_faces_import_setting_description": "Importar caras desde los metadatos EXIF y auxiliares de una imagen", + "metadata_settings": "Configuración de metadatos", + "metadata_settings_description": "Administrar la configuración de metadatos", "migration_job": "Migración", "migration_job_description": "Migrar miniaturas de archivos y caras a la estructura de carpetas más reciente", "no_paths_added": "No se han añadido carpetas", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "NOTA: No se puede cambiar posteriormente!", "note_unlimited_quota": "Nota: usa 0 para espacio sin límites", "notification_email_from_address": "Desde", - "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host del servidor de correo electrónico (por ejemplo: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar errores de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar los errores de validación del certificado TLS (no recomendado)", @@ -173,10 +190,10 @@ "oauth_issuer_url": "URL del emisor", "oauth_mobile_redirect_uri": "URI de redireccionamiento móvil", "oauth_mobile_redirect_uri_override": "Sobreescribir URI de redirección móvil", - "oauth_mobile_redirect_uri_override_description": "Habilítelo cuando 'app.immich:/' sea un URI de redireccionamiento no válido.", + "oauth_mobile_redirect_uri_override_description": "Habilitar cuando el proveedor de OAuth no permite una URI móvil, como '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de firma de perfiles", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para firmar el perfil del usuario.", - "oauth_scope": "Scope", + "oauth_scope": "Ámbito", "oauth_settings": "OAuth", "oauth_settings_description": "Administrar la configuración de inicio de sesión de OAuth", "oauth_settings_more_details": "Para más detalles acerca de esta característica, consulte la documentación.", @@ -187,25 +204,28 @@ "oauth_storage_quota_claim_description": "Establezca automáticamente la cuota de almacenamiento del usuario al valor de esta solicitud.", "oauth_storage_quota_default": "Cuota de almacenamiento predeterminada (GiB)", "oauth_storage_quota_default_description": "Cuota en GiB que se utilizará cuando no se proporcione ninguna por defecto (ingrese 0 para una cuota ilimitada).", - "offline_paths": "Carpetas sin conexión", + "offline_paths": "Rutas sin conexión", "offline_paths_description": "Estos resultados pueden deberse al eliminar manualmente archivos que no son parte de una biblioteca externa.", "password_enable_description": "Iniciar sesión con correo electrónico y contraseña", "password_settings": "Contraseña de Acceso", "password_settings_description": "Administrar la configuración de inicio de sesión con contraseña", "paths_validated_successfully": "Todas las carpetas se han validado satisfactoriamente", + "person_cleanup_job": "Limpieza de personas", "quota_size_gib": "Tamaño de Quota (GiB)", - "refreshing_all_libraries": "Actualizando todas las bibliotecas", - "registration": "Registrar Administrador", - "registration_description": "Dado que usted es el primer usuario del sistema, se le asignará como administrador y será responsable de las tareas administrativas, y usted creará usuarios adicionales.", - "removing_offline_files": "Eliminando los archivos offline", - "repair_all": "Reparar Todo", - "repair_matched_items": "Coincidencia {count, plural, one {# item} other {# items}}", + "refreshing_all_libraries": "Actualizar todas las bibliotecas", + "registration": "Registrar administrador", + "registration_description": "Dado que eres el primer usuario del sistema, se te asignará como Admin y serás responsable de las tareas administrativas, y de crear a los usuarios adicionales.", + "removing_deleted_files": "Eliminando archivos sin conexión", + "repair_all": "Reparar todo", + "repair_matched_items": "Coincidencia {count, plural, one {# elemento} other {# elementos}}", "repaired_items": "Reparado {count, plural, one {# elemento} other {# elementos}}", "require_password_change_on_login": "Requerir que el usuario cambie la contraseña en el primer inicio de sesión", "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", "scanning_library_for_changed_files": "Escanear archivos modificados en biblioteca", "scanning_library_for_new_files": "Escanear nuevos archivos en biblioteca", + "search_jobs": "Buscar trabajo...", "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)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Administre la estructura de carpetas y el nombre de archivo del recurso cargado", "storage_template_user_label": "{label} es la etiqueta de almacenamiento del usuario", "system_settings": "Ajustes del Sistema", + "tag_cleanup_job": "Limpieza de etiquetas", "theme_custom_css_settings": "CSS Personalizado", "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes Tema", @@ -257,7 +278,7 @@ "transcoding_audio_codec": "Codec de audio", "transcoding_audio_codec_description": "Opus es la opción de mayor calidad, pero tiene menor compatibilidad con dispositivos o software antiguos.", "transcoding_bitrate_description": "Vídeos con una tasa de bits superior a la máxima o que no están en un formato aceptado", - "transcoding_codecs_learn_more": "Para obtener más información sobre la terminología utilizada aquí, consulte la documentación de FFmpeg para H.264 codec, HEVC codec y VP9 codec.", + "transcoding_codecs_learn_more": "Para obtener más información sobre la terminología utilizada aquí, consulte la documentación de FFmpeg sobre los codecs H.264, HEVC y VP9.", "transcoding_constant_quality_mode": "Modo de calidad constante", "transcoding_constant_quality_mode_description": "ICQ es mejor que CQP, pero algunos dispositivos de aceleración de hardware no admiten este modo. Al configurar esta opción, se preferirá el modo especificado cuando se utilice codificación basada en calidad. NVENC lo ignora porque no es compatible con ICQ.", "transcoding_constant_rate_factor": "Factor de tasa constante (-crf)", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Aceleración por Hardware", "transcoding_hardware_acceleration_description": "Experimental; mucho más rápido, pero tendrá menor calidad con la misma tasa de bits", "transcoding_hardware_decoding": "Decodificación por hardware", - "transcoding_hardware_decoding_setting_description": "Se aplica únicamente a NVENC, QSV y RKMPP. Habilita la aceleración de un extremo a otro en lugar de solo acelerar la codificación. Puede que no funcione en todos los vídeos.", + "transcoding_hardware_decoding_setting_description": "Permite la aceleración de extremo a extremo en lugar de acelerar únicamente la codificación. Puede que no funcione en todos los vídeos.", "transcoding_hevc_codec": "Codec HEVC", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Se aplica únicamente a VAAPI y QSV. Establece el nodo dri utilizado para la transcodificación de hardware.", "transcoding_preset_preset": "Configuración predefinida (-preset)", - "transcoding_preset_preset_description": "Velocidad de compresión. Los ajustes preestablecidos más lentos producen archivos más pequeños y aumentan la calidad cuando se apunta a una determinada tasa de bits. VP9 ignora las velocidades superiores a \"más rápidas\".", + "transcoding_preset_preset_description": "Velocidad de compresión. Los preajustes más lentos producen archivos más pequeños, y aumentan la calidad cuando se apunta a una determinada tasa de bits. VP9 ignora las velocidades superiores a 'más rápido'.", "transcoding_reference_frames": "Frames de referencia", "transcoding_reference_frames_description": "El número de fotogramas a los que hacer referencia al comprimir un fotograma determinado. Los valores más altos mejoran la eficiencia de la compresión, pero ralentizan la codificación. 0 establece este valor automáticamente.", "transcoding_required_description": "Sólo vídeos que no estén en un formato soportado", @@ -286,7 +307,7 @@ "transcoding_settings_description": "Administrar la resolución y la información de codificación de los archivos de video", "transcoding_target_resolution": "Resolución deseada", "transcoding_target_resolution_description": "Las resoluciones más altas pueden conservar más detalles, pero la codificación tarda más, tienen tamaños de archivo más grandes y pueden reducir la capacidad de respuesta de la aplicación.", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "AQ temporal", "transcoding_temporal_aq_description": "Se aplica únicamente a NVENC. Aumenta la calidad de escenas con mucho detalle y poco movimiento. Puede que no sea compatible con dispositivos más antiguos.", "transcoding_threads": "Hilos", "transcoding_threads_description": "Los valores más altos conducen a una codificación más rápida, pero dejan menos espacio para que el servidor procese otras tareas mientras está activo. Este valor no debe ser mayor que la cantidad de núcleos de CPU. Maximiza la utilización si se establece en 0.", @@ -307,7 +328,8 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", - "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# day} other {# days}}.", + "user_cleanup_job": "Limpieza de usuarios", + "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", "user_delete_immediately": "La cuenta {user} y los archivos se pondrán en cola para su eliminación permanente inmediatamente.", @@ -320,7 +342,8 @@ "user_settings": "Ajustes de usuario", "user_settings_description": "Administrar la configuración del usuario", "user_successfully_removed": "El usuario {email} ha sido eliminado exitosamente.", - "version_check_enabled_description": "Habilite las comprobaciones periódicas a GitHub para verificar nuevas versiones", + "version_check_enabled_description": "Activar la comprobación de la versión", + "version_check_implications": "La función de comprobación de versiones depende de la comunicación periódica con github.com", "version_check_settings": "Verificar Versión", "version_check_settings_description": "Activar/desactivar la notificación de nueva versión", "video_conversion_job": "Transcodificar vídeos", @@ -330,13 +353,14 @@ "admin_password": "Contraseña del Administrador", "administration": "Administración", "advanced": "Avanzada", - "age_months": "Tiempo {months, plural, one {# month} other {# months}}", - "age_year_months": "1 año, {months, plural, one {# month} other {# months}}", - "age_years": "{years, plural, other {Age #}}", + "age_months": "Tiempo {months, plural, one {# mes} other {# meses}}", + "age_year_months": "1 año, {months, plural, one {# mes} other {# meses}}", + "age_years": "Edad {years, plural, one {# año} other {# años}}", "album_added": "Álbum añadido", "album_added_notification_setting_description": "Reciba una notificación por correo electrónico cuando lo agreguen a un álbum compartido", "album_cover_updated": "Portada del álbum actualizada", - "album_delete_confirmation": "¿Estás seguro de que deseas eliminar el álbum {album}?\nSi se comparte este álbum, otros usuarios ya no podrán acceder a él.", + "album_delete_confirmation": "¿Estás seguro de que deseas eliminar el álbum {album}?", + "album_delete_confirmation_description": "Si este álbum se comparte, otros usuarios ya no podrán acceder a él.", "album_info_updated": "Información del álbum actualizada", "album_leave": "¿Abandonar el álbum?", "album_leave_confirmation": "¿Estás seguro de que quieres dejar {album}?", @@ -347,10 +371,10 @@ "album_share_no_users": "Parece que has compartido este álbum con todos los usuarios o no tienes ningún usuario con quien compartirlo.", "album_updated": "Album actualizado", "album_updated_setting_description": "Reciba una notificación por correo electrónico cuando un álbum compartido tenga nuevos archivos", - "album_user_left": "Izquierda {album}", + "album_user_left": "Salida {album}", "album_user_removed": "Eliminado a {user}", "album_with_link_access": "Permita que cualquier persona con el enlace vea fotos y personas en este álbum.", - "albums": "Albums", + "albums": "Álbumes", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbumes}}", "all": "Todos", "all_albums": "Todos los albums", @@ -360,6 +384,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", + "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.", "api_key_empty": "El nombre de su clave API no debe estar vacío", @@ -368,10 +393,10 @@ "appears_in": "Aparece en", "archive": "Archivo", "archive_or_unarchive_photo": "Archivar o restaurar foto", - "archive_size": "Tamaño de archivo", + "archive_size": "Tamaño del archivo", "archive_size_description": "Configure el tamaño del archivo para descargas (en GB)", "archived": "Archivado", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, one {# archivado} other {# archivados}}", "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", @@ -380,23 +405,24 @@ "asset_filename_is_offline": "El archivo {filename} está offline", "asset_has_unassigned_faces": "El archivo no tiene rostros asignados", "asset_hashing": "Hashing...", - "asset_offline": "Archivos fuera de linea", - "asset_offline_description": "Este archivo está offline. Immich no puede acceder a la ubicación de su archivo. Asegúrese de que el archivo esté disponible y luego vuelva a escanear la biblioteca.", + "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...", "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", "assets_added_to_name_count": "Añadido {count, plural, one {# asset} other {# assets}} a {hasName, select, true {{name}} other {new album}}", - "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_count": "{count, plural, one {# activo} other {# activos}}", "assets_moved_to_trash": "Se movió {count, plural, one {# activo} other {# activos}} a la papelera", - "assets_moved_to_trash_count": "Movido {count, plural, one {# asset} other {# assets}} a la papelera", - "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", - "assets_removed_count": "Eliminado {count, plural, one {# asset} other {# assets}}", - "assets_restore_confirmation": "¿Está seguro de que desea restaurar todos sus archivos eliminados? ¡No puedes deshacer esta acción!", - "assets_restored_count": "Restaurado {count, plural, one {# asset} other {# assets}}", - "assets_trashed_count": "Borrado {count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "{count, plural, one {# elemento movido} other {# elementos movidos}} a la papelera", + "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", + "assets_removed_count": "Eliminado {count, plural, one {# elemento} other {# elementos}}", + "assets_restore_confirmation": "¿Estás seguro de que quieres restaurar todos tus activos eliminados? ¡No puede deshacer esta acción! Tenga en cuenta que los archivos sin conexión no se pueden restaurar de esta manera.", + "assets_restored_count": "Restaurado {count, plural, one {# elemento} other {# elementos}}", + "assets_trashed_count": "Borrado {count, plural, one {# elemento} other {# elementos}}", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Atrás", @@ -405,9 +431,10 @@ "birthdate_saved": "Fecha de nacimiento guardada con éxito", "birthdate_set_description": "La fecha de nacimiento se utiliza para calcular la edad de esta persona en el momento de la fotografía.", "blurred_background": "Fondo borroso", + "bugs_and_feature_requests": "Errores y solicitudes de funciones", "build": "Compilación", - "build_image": "Construir Imagen", - "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# duplicate asset} other {# duplicate assets}}? 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!", + "build_image": "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.", "buy": "Comprar Immich", @@ -425,7 +452,7 @@ "cant_search_places": "No se pueden buscar lugares", "change_date": "Cambiar fecha", "change_expiration_time": "Cambiar fecha de caducidad", - "change_location": "Cambiar localización", + "change_location": "Cambiar ubicación", "change_name": "Cambiar nombre", "change_name_successfully": "Nombre cambiado correctamente", "change_password": "Cambiar Contraseña", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Borrar búsquedas recientes", "clear_message": "Limpiar mensaje", "clear_value": "Limpiar valor", + "clockwise": "En el sentido de las agujas del reloj", "close": "Cerrar", "collapse": "Agrupar", "collapse_all": "Desplegar todo", + "color": "Color", "color_theme": "Color del tema", "comment_deleted": "Comentario borrado", "comment_options": "Opciones de comentarios", @@ -477,6 +506,8 @@ "create_new_person": "Crear nueva persona", "create_new_person_hint": "Asignar los archivos seleccionados a una nueva persona", "create_new_user": "Crear nuevo usuario", + "create_tag": "Crear etiqueta", + "create_tag_description": "Crear una nueva etiqueta. Para las etiquetas anidadas, ingresa la ruta completa de la etiqueta, incluidas las barras diagonales.", "create_user": "Crear usuario", "created": "Creado", "current_device": "Dispositivo actual", @@ -500,13 +531,17 @@ "delete_library": "Eliminar biblioteca", "delete_link": "Eliminar enlace", "delete_shared_link": "Eliminar enlace compartido", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "¿Estás seguro de que deseas eliminar la etiqueta {tagName} ?", "delete_user": "Eliminar usuario", "deleted_shared_link": "Enlace compartido eliminado", + "deletes_missing_assets": "Elimina archivos que faltan en el disco duro", "description": "Descripción", "details": "DETALLES", "direction": "Dirección", "disabled": "Deshabilitado", "disallow_edits": "Bloquear edición", + "discord": "Discord", "discover": "Descubrir", "dismiss_all_errors": "Descartar todos los errores", "dismiss_error": "Descartar error", @@ -515,8 +550,11 @@ "display_original_photos": "Mostrar fotos originales", "display_original_photos_setting_description": "Preferir mostrar la foto original al ver un archivo en lugar de miniaturas cuando el archivo original es compatible con la web. Esto puede resultar en velocidades de visualización de fotografías más lentas.", "do_not_show_again": "No volver a mostrar este mensaje otra vez", + "documentation": "Documentación", "done": "Hecho", "download": "Descargar", + "download_include_embedded_motion_videos": "Vídeos incrustados", + "download_include_embedded_motion_videos_description": "Incluir vídeos incrustados en fotografías en movimiento como un archivo separado", "download_settings": "Descargar", "download_settings_description": "Administrar configuraciones relacionadas con la descarga de archivos", "downloading": "Descargando", @@ -546,10 +584,15 @@ "edit_location": "Editar ubicación", "edit_name": "Cambiar nombre", "edit_people": "Editar persona", + "edit_tag": "Editar etiqueta", "edit_title": "Editar Titulo", "edit_user": "Editar usuario", "edited": "Editado", "editor": "Editor", + "editor_close_without_save_prompt": "No se guardarán los cambios", + "editor_close_without_save_title": "¿Cerrar el editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporciones del aspecto", + "editor_crop_tool_h2_rotation": "Rotación", "email": "Correo", "empty": "", "empty_album": "Álbum vacío", @@ -567,7 +610,7 @@ "cant_apply_changes": "No se pueden aplicar los cambios", "cant_change_activity": "No se puede realizar la actividad {enabled, select, true {disable} other {enable}}", "cant_change_asset_favorite": "No se puede cambiar favorito para este archivo", - "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# asset} other {# assets}}", + "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# elemento} other {# elementos}}", "cant_get_faces": "No se encuentran caras", "cant_get_number_of_comments": "No se puede obtener la cantidad de comentarios", "cant_search_people": "No se puede buscar a personas", @@ -594,7 +637,7 @@ "failed_to_unstack_assets": "Error al desagrupar los archivos", "import_path_already_exists": "Esta ruta de importación ya existe.", "incorrect_email_or_password": "Contraseña o email incorrecto", - "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpetas} other {# carpetas}}", + "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpeta} other {# carpetas}}", "profile_picture_transparent_pixels": "Las imágenes de perfil no pueden tener píxeles transparentes. Por favor amplíe y/o mueva la imagen.", "quota_higher_than_disk_size": "Se ha establecido una cuota superior al tamaño del disco", "repair_unable_to_check_items": "No se puede verificar {count, select, one {elemento} other {elementos}}", @@ -612,7 +655,7 @@ "unable_to_change_favorite": "Imposible cambiar el archivo favorito", "unable_to_change_location": "No se puede cambiar de ubicación", "unable_to_change_password": "No se puede cambiar la contraseña", - "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# person} other {# people}}", + "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# persona} other {# personas}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "No se puede completar el inicio de sesión de OAuth", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "No se puede obtener el número de comentarios", "unable_to_get_shared_link": "Error al obtener el enlace compartido", "unable_to_hide_person": "No se puede ocultar a la persona", + "unable_to_link_motion_video": "No se puede enlazar el vídeo en movimiento", "unable_to_link_oauth_account": "No se puede vincular la cuenta OAuth", "unable_to_load_album": "No se puede cargar el álbum", "unable_to_load_asset_activity": "No se puede cargar la actividad de los archivos", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "No se puede eliminar la clave API", "unable_to_remove_assets_from_shared_link": "No se pueden eliminar archivos desde el enlace compartido", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "No se pueden eliminar archivos sin conexión", "unable_to_remove_library": "No se puede eliminar la biblioteca", - "unable_to_remove_offline_files": "No se pueden eliminar archivos sin conexión", "unable_to_remove_partner": "No se puede eliminar el invitado", "unable_to_remove_reaction": "No se puede eliminar la reacción", "unable_to_remove_user": "", @@ -679,6 +723,7 @@ "unable_to_submit_job": "No se puede enviar el trabajo", "unable_to_trash_asset": "No se puede eliminar el archivo", "unable_to_unlink_account": "No se puede desvincular la cuenta", + "unable_to_unlink_motion_video": "No se puede desvincular el vídeo en movimiento", "unable_to_update_album_cover": "No se puede actualizar la portada del álbum", "unable_to_update_album_info": "No se puede actualizar la información del álbum", "unable_to_update_library": "No se puede actualizar la biblioteca", @@ -692,13 +737,14 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exif": "Exif", + "exif": "EXIF", "exit_slideshow": "Salir de la presentación", "expand_all": "Expandir todo", "expire_after": "Expirar después de", "expired": "Caducado", "expires_date": "Expira el {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exportar", "export_as_json": "Exportar a JSON", "extension": "Extension", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Foto destacada actualizada", "featurecollection": "", + "features": "Características", + "features_setting_description": "Administrar las funciones de la aplicación", "file_name": "Nombre de archivo", "file_name_or_extension": "Nombre del archivo o extensión", "filename": "Nombre del archivo", @@ -720,6 +768,8 @@ "filter_people": "Filtrar personas", "find_them_fast": "Encuéntrelos rápidamente por nombre con la búsqueda", "fix_incorrect_match": "Corregir coincidencia incorrecta", + "folders": "Carpetas", + "folders_feature_description": "Explorar la vista de carpetas para las fotos y los videos en el sistema de archivos", "force_re-scan_library_files": "Forzar reescaneo de todos los archivos de la biblioteca", "forward": "Reenviar", "general": "General", @@ -776,7 +826,7 @@ }, "invite_people": "Invitar a Personas", "invite_to_album": "Invitar al álbum", - "items_count": "{count, plural, one {# item} other {# items}}", + "items_count": "{count, plural, one {# elemento} other {# elementos}}", "job_settings_description": "", "jobs": "Tareas", "keep": "Conservar", @@ -819,6 +869,7 @@ "license_trial_info_4": "Por favor, considera la compra de una licencia para apoyar el desarrollo continuo del servicio", "light": "Claro", "like_deleted": "Me gusta eliminado", + "link_motion_video": "Enlazar vídeo en movimiento", "link_options": "Opciones de enlace", "link_to_oauth": "Enlace a OAuth", "linked_oauth_account": "Cuenta OAuth vinculada", @@ -837,6 +888,7 @@ "look": "Mirar", "loop_videos": "Vídeos en bucle", "loop_videos_description": "Habilite la reproducción automática de un video en el visor de detalles.", + "main_branch_warning": "Estás ejecutando una compilación desde la rama principal. ¡Recomendamos encarecidamente usar una versión de lanzamiento!", "make": "Marca", "manage_shared_links": "Administrar enlaces compartidos", "manage_sharing_with_partners": "Administrar el uso compartido con invitados", @@ -861,7 +913,7 @@ "merge_people_limit": "Solo puedes fusionar hasta 5 caras a la vez", "merge_people_prompt": "¿Quieres fusionar a estas personas? Esta acción es irreversible.", "merge_people_successfully": "Personas fusionadas correctamente", - "merged_people_count": "Fusionar {count, plural, one {# person} other {# people}}", + "merged_people_count": "Fusionada {count, plural, one {# persona} other {# personas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Perdido", @@ -898,7 +950,7 @@ "no_results": "Sin resultados", "no_results_description": "Pruebe con un sinónimo o una palabra clave más general", "no_shared_albums_message": "Crea un álbum para compartir fotos y vídeos con personas de tu red", - "not_in_any_album": "Nada en ningún álbum", + "not_in_any_album": "Sin álbum", "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar la etiqueta de almacenamiento a los archivos cargados previamente, ejecute el", "note_unlimited_quota": "Nota: Ingrese 0 para cuota ilimitada", "notes": "Notas", @@ -906,24 +958,27 @@ "notifications": "Notificaciones", "notifications_setting_description": "Administrar notificaciones", "oauth": "OAuth", + "official_immich_resources": "Recursos oficiales de Immich", "offline": "Desconectado", "offline_paths": "Rutas sin conexión", "offline_paths_description": "Estos resultados pueden deberse a la eliminación manual de archivos que no forman parte de una biblioteca externa.", "ok": "Sí", "oldest_first": "Los más antiguos primero", "onboarding": "Incorporando", + "onboarding_privacy_description": "Las siguientes funciones (opcionales) dependen de servicios externos y pueden desactivarse en cualquier momento en los ajustes.", "onboarding_theme_description": "Elija un color de tema para su instancia. Puedes cambiar esto más tarde en tu configuración.", "onboarding_welcome_description": "Configuremos su instancia con algunas configuraciones comunes.", "onboarding_welcome_user": "Bienvenido, {user}", "online": "En línea", "only_favorites": "Solo favoritos", "only_refreshes_modified_files": "Solo actualiza los archivos modificados", + "open_in_map_view": "Abrir en la vista del mapa", "open_in_openstreetmap": "Abrir en OpenStreetMap", "open_the_search_filters": "Abre los filtros de búsqueda", "options": "Opciones", "or": "o", "organize_your_library": "Organiza tu biblioteca", - "original": "oeiginal", + "original": "original", "other": "Otro", "other_devices": "Otro dispositivo", "other_variables": "Otras variables", @@ -940,9 +995,9 @@ "password_required": "Contraseña requerida", "password_reset_success": "Restablecimiento de contraseña exitoso", "past_durations": { - "days": "Pasados {days, plural, one {day} other {# days}}", - "hours": "Pasadas {hours, plural, one {hour} other {# hours}}", - "years": "Pasado(s) {years, plural, one {year} other {# years}}" + "days": "Pasados {days, plural, one {día} other {# días}}", + "hours": "Pasadas {hours, plural, one {hora} other {# horas}}", + "years": "Pasado(s) {years, plural, one {año} other {# años}}" }, "path": "Ruta", "pattern": "Patrón", @@ -951,23 +1006,24 @@ "paused": "Detenido", "pending": "Pendiente", "people": "Personas", - "people_edits_count": "Editado {count, plural, one {# person} other {# people}}", + "people_edits_count": "Editada {count, plural, one {# persona} other {# personas}}", + "people_feature_description": "Explorar fotos y vídeos agrupados por personas", "people_sidebar_description": "Mostrar un enlace a Personas en la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Advertencia de eliminación permanente", "permanent_deletion_warning_setting_description": "Mostrar una advertencia al eliminar archivos permanentemente", "permanently_delete": "Borrar permanentemente", - "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {asset} other {assets}}", - "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {¿este activo?} other {¿estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {elemento} other {elementos}}", + "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {este activo?} other {estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", "permanently_deleted_asset": "Archivo eliminado permanentemente", "permanently_deleted_assets": "Eliminado permanentemente {count, plural, one {# activo} other {# activos}}", - "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", + "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", "person": "Persona", - "person_hidden": "{name}{hidden, select, true { (hidden)} other {}}", + "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", "photos_and_videos": "Fotos y Videos", - "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotos}}", + "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", "photos_from_previous_years": "Fotos de años anteriores", "pick_a_location": "Elige una ubicación", "place": "Lugar", @@ -984,11 +1040,12 @@ "previous_memory": "Recuerdo anterior", "previous_or_next_photo": "Foto anterior o siguiente", "primary": "Básico", + "privacy": "Privacidad", "profile_image_of_user": "Foto de perfil de {user}", "profile_picture_set": "Conjunto de imágenes de perfil.", "public_album": "Álbum público", "public_share": "Compartir públicamente", - "purchase_account_info": "Soporte", + "purchase_account_info": "Seguidor", "purchase_activated_subtitle": "Gracias por apoyar a Immich y al software de código abierto", "purchase_activated_time": "Activado el {date, date}", "purchase_activated_title": "Su clave ha sido activada correctamente", @@ -1021,38 +1078,45 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clave del producto del servidor la administra el administrador", "range": "", + "rating": "Valoración", + "rating_clear": "Borrar calificación", + "rating_count": "{count, plural, one {# estrella} other {# estrellas}}", + "rating_description": "Mostrar la clasificación exif en el panel de información", "raw": "", "reaction_options": "Opciones de reacción", "read_changelog": "Leer registro de cambios", "reassign": "Reasignar", - "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", - "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# asset} other {# assets}} a un nuevo usuario", + "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a {name, select, null {una persona existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a un nuevo usuario", "reassing_hint": "Asignar archivos seleccionados a una persona existente", "recent": "Reciente", "recent_searches": "Búsquedas recientes", "refresh": "Actualizar", - "refresh_encoded_videos": "Actualizar vídeos codificados", - "refresh_metadata": "Actualizar metadatos", - "refresh_thumbnails": "Actualizar miniaturas", - "refreshed": "Actualizado", - "refreshes_every_file": "Actualiza cada archivo", - "refreshing_encoded_video": "Actualizando videos codificados", - "refreshing_metadata": "Actualizando metadatos", - "regenerating_thumbnails": "Actualizando miniaturas", + "refresh_encoded_videos": "Recargar los vídeos codificados", + "refresh_faces": "Actualizar caras", + "refresh_metadata": "Recargar metadatos", + "refresh_thumbnails": "Recargar miniaturas", + "refreshed": "Recargado", + "refreshes_every_file": "Recargar todos los archivos nuevos y existentes", + "refreshing_encoded_video": "Recargando los videos codificados", + "refreshing_faces": "Recargando caras", + "refreshing_metadata": "Recargando metadatos", + "regenerating_thumbnails": "Recargando miniaturas", "remove": "Eliminar", - "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del álbum?", - "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del enlace compartido?", + "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del álbum?", + "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del enlace compartido?", "remove_assets_title": "¿Eliminar activos?", "remove_custom_date_range": "Eliminar intervalo de fechas personalizado", + "remove_deleted_assets": "Eliminar archivos sin conexión", "remove_from_album": "Eliminar del álbum", "remove_from_favorites": "Quitar de favoritos", "remove_from_shared_link": "Eliminar desde enlace compartido", - "remove_offline_files": "Eliminar archivos sin conexión", "remove_user": "Eliminar usuario", "removed_api_key": "Clave API eliminada: {name}", "removed_from_archive": "Eliminado del archivo", "removed_from_favorites": "Eliminado de favoritos", - "removed_from_favorites_count": "{count, plural, other {Removed #}} de favoritos", + "removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos", + "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}", "rename": "Renombrar", "repair": "Reparar", "repair_no_results_message": "Los archivos perdidos y sin seguimiento aparecerán aquí", @@ -1084,6 +1148,7 @@ "say_something": "Comenta algo", "scan_all_libraries": "Escanear todas las bibliotecas", "scan_all_library_files": "Vuelva a escanear todos los archivos de la biblioteca", + "scan_library": "Escanear", "scan_new_library_files": "Escanear nuevos archivos de biblioteca", "scan_settings": "Configuración de escaneo", "scanning_for_album": "Buscando álbum...", @@ -1099,9 +1164,12 @@ "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_settings": "Ajustes de la búsqueda", "search_state": "Buscar región/estado...", + "search_tags": "Buscando etiquetas...", "search_timezone": "Buscar zona horaria...", "search_type": "Tipo de búsqueda", "search_your_photos": "Busca tus fotos", @@ -1115,19 +1183,19 @@ "select_face": "Seleccionar cara", "select_featured_photo": "Seleccionar foto principal", "select_from_computer": "Seleccionar desde el PC", - "select_keep_all": "Mantener toda la selección", + "select_keep_all": "Conservar todo", "select_library_owner": "Seleccionar propietario de la biblioteca", "select_new_face": "Seleccionar nueva cara", "select_photos": "Seleccionar Fotos", - "select_trash_all": "Enviar la selección a la papelera", + "select_trash_all": "Descartar todo", "selected": "Seleccionado", - "selected_count": "{count, plural, other {# selected}}", + "selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}", "send_message": "Enviar mensaje", "send_welcome_email": "Enviar correo de bienvenida", "server": "Servidor", "server_offline": "Servidor desconectado", "server_online": "Servidor en línea", - "server_stats": "Estadísticas Servidor", + "server_stats": "Estadísticas del servidor", "server_version": "Versión del servidor", "set": "Establecer", "set_as_album_cover": "Establecer portada del álbum", @@ -1143,6 +1211,7 @@ "shared_by_user": "Compartido por {user}", "shared_by_you": "Compartido por ti", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opciones de enlaces compartidos", "shared_links": "Enlaces compartidos", "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos y vídeos compartidos.}}", "shared_with_partner": "Compartido con {partner}", @@ -1151,6 +1220,7 @@ "sharing_sidebar_description": "Muestra un enlace a \"Compartido\" en el menú lateral", "shift_to_permanent_delete": "presiona ⇧ para eliminar permanentemente el archivo", "show_album_options": "Mostrar ajustes del álbum", + "show_albums": "Mostrar álbumes", "show_all_people": "Mostrar todas las personas", "show_and_hide_people": "Mostrar y ocultar personas", "show_file_location": "Mostrar carpeta del archivo", @@ -1165,13 +1235,18 @@ "show_person_options": "Mostrar opciones de la persona", "show_progress_bar": "Mostrar barra de progreso", "show_search_options": "Mostrar opciones de búsqueda", + "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", "shuffle": "Modo aleatorio", + "sidebar": "Barra lateral", + "sidebar_display_description": "Muestra un enlace a la vista en la barra lateral", "sign_out": "Salir", "sign_up": "Registrarse", "size": "Tamaño", "skip_to_content": "Saltar al contenido", + "skip_to_folders": "Ir a las carpetas", + "skip_to_tags": "Ir a las etiquetas", "slideshow": "Diapositivas", "slideshow_settings": "Ajustes de diapositivas", "sort_albums_by": "Ordenar álbumes por...", @@ -1181,10 +1256,12 @@ "sort_oldest": "Foto más antigua", "sort_recent": "Foto más reciente", "sort_title": "Título", - "source": "Fuente", + "source": "Origen", "stack": "Apilar", + "stack_duplicates": "Apilar duplicados", + "stack_select_one_photo": "Selecciona una imagen principal para la pila", "stack_selected_photos": "Apilar fotos seleccionadas", - "stacked_assets_count": "Apilados {count, plural, one {# asset} other {# assets}}", + "stacked_assets_count": "Apilado(s) {count, plural, one {# activo} other {# activos}}", "stacktrace": "Stacktrace", "start": "Inicio", "start_date": "Fecha de inicio", @@ -1200,56 +1277,72 @@ "submit": "Enviar", "suggestions": "Sugerencias", "sunrise_on_the_beach": "Amanecer en la playa", + "support": "Soporte", + "support_and_feedback": "Soporte y comentarios", + "support_third_party_description": "Su instalación de immich fue empaquetada por un tercero. Los problemas que experimenta pueden ser causados por ese paquete, así que por favor plantee problemas con ellos en primer lugar usando los enlaces inferiores.", "swap_merge_direction": "Alternar dirección de mezcla", "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar activos", + "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_updated": "Etiqueta actualizada: {tag}", + "tagged_assets": "Etiquetado(s) {count, plural, one {# activo} other {# activos}}", + "tags": "Etiquetas", "template": "Plantilla", "theme": "Tema", "theme_selection": "Selección de tema", "theme_selection_description": "Establece el tema automáticamente como \"claro\" u \"oscuro\" según las preferencias del sistema/navegador", "they_will_be_merged_together": "Se fusionarán entre sí", + "third_party_resources": "Recursos de terceros", "time_based_memories": "Recuerdos basados en tiempo", "timezone": "Zona horaria", "to_archive": "Archivar", "to_change_password": "Cambiar contraseña", "to_favorite": "A los favoritos", "to_login": "Iniciar Sesión", - "to_trash": "Papelera", + "to_parent": "Ir a los padres", + "to_root": "Para root", + "to_trash": "Descartar", "toggle_settings": "Alternar ajustes", - "toggle_theme": "Alternar tema", + "toggle_theme": "Alternar tema oscuro", "toggle_visibility": "Alternar visibilidad", "total_usage": "Uso total", "trash": "Papelera", - "trash_all": "Enviar todo a la papelera", - "trash_count": "Papelera {count, number}", + "trash_all": "Descartar todo", + "trash_count": "Descartar {count, number}", "trash_delete_asset": "Borrar/Eliminar archivo", "trash_no_results_message": "Las fotos y videos que se envíen a la papelera aparecerán aquí.", "trashed_items_will_be_permanently_deleted_after": "Los elementos en la papelera serán eliminados permanentemente tras {days, plural, one {# día} other {# días}}.", "type": "Tipo", "unarchive": "Desarchivar", "unarchived": "Restaurado", - "unarchived_count": "{count, plural, other {Unarchived #}}", + "unarchived_count": "{count, plural, one {# No archivado} other {# No archivados}}", "unfavorite": "Retirar favorito", "unhide_person": "Mostrar persona", "unknown": "Desconocido", "unknown_album": "Álbum 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", "unnamed_album": "Album sin nombre", + "unnamed_album_delete_confirmation": "¿Seguro que quieres borrar este álbum?", "unnamed_share": "Compartido sin nombre", "unsaved_change": "Cambio no guardado", "unselect_all": "Limpiar selección", "unselect_all_duplicates": "Deseleccionar todos los duplicados", "unstack": "Desapilar", - "unstacked_assets_count": "Sin apilar {count, plural, one {# asset} other {# assets}}", + "unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}", "untracked_files": "Archivos no monitorizados", "untracked_files_decription": "Estos archivos no están siendo monitorizados por la aplicación. Es posible que sean resultado de errores al moverlos, cargas interrumpidas o por un fallo de la aplicación", "up_next": "A continuación", "updated_password": "Contraseña actualizada", "upload": "Subir", "upload_concurrency": "Cargas simultáneas", - "upload_errors": "Carga completada con {count, plural, one {# error} other {# errors}}, actualice la página para ver los nuevos recursos de carga.", + "upload_errors": "Carga completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de carga.", "upload_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}", "upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_status_duplicates": "Duplicados", @@ -1275,7 +1368,9 @@ "variables": "Variables", "version": "Versión", "version_announcement_closing": "Tu amigo, Alex", - "version_announcement_message": "Hola amigo, hay una nueva versión de la aplicación, por favor tómete tu tiempo para visitar las notas de la versión y asegúrate de que tu docker-compose.yml, y la configuración .env esté actualizada para evitar cualquier configuración incorrecta, especialmente si usas WatchTower o cualquier mecanismo que maneje la actualización automática de tu aplicación.", + "version_announcement_message": "Hola Amigo: Hay una nueva versión de la aplicación, por favor, tómate tu tiempo para visitar las notas de la versión y asegúrate de que tu docker-compose.yml y la configuración .env estén actualizadas para evitar cualquier configuración incorrecta, especialmente si usas WatchTower o cualquier mecanismo que maneje la actualización automática de tu aplicación.", + "version_history": "Historial de versiones", + "version_history_item": "Instalada la {version} el {date}", "video": "Vídeo", "video_hover_setting": "Iniciar vídeo al pasar por encima", "video_hover_setting_description": "Reproducir el vídeo cuando el ratón está encima de un vídeo. Aunque esté desactivado, se iniciará cuando el cursor del ratón esté sobre el icono de \"reproducir\".", @@ -1285,19 +1380,20 @@ "view_album": "Ver Álbum", "view_all": "Ver todas", "view_all_users": "Mostrar todos los usuarios", + "view_in_timeline": "Mostrar en la línea de tiempo", "view_links": "Mostrar enlaces", "view_next_asset": "Mostrar siguiente elemento", "view_previous_asset": "Mostrar elemento anterior", "view_stack": "Ver Pila", "viewer": "Visualizador", - "visibility_changed": "Visibilidad cambiada para {count, plural, one {# person} other {# people}}", + "visibility_changed": "Visibilidad cambiada para {count, plural, one {# persona} other {# personas}}", "waiting": "Esperando", "warning": "Advertencia", "week": "Semana", "welcome": "Bienvenido", - "welcome_to_immich": "Bienvenido a immich", + "welcome_to_immich": "Bienvenido a Immich", "year": "Año", - "years_ago": "Hace {years, plural, one {# year} other {# years}}", + "years_ago": "Hace {years, plural, one {# año} other {# años}}", "yes": "Sí", "you_dont_have_any_shared_links": "No tienes ningún enlace compartido", "zoom_image": "Acercar Imagen" diff --git a/i18n/et.json b/i18n/et.json new file mode 100644 index 0000000000..d46714abe9 --- /dev/null +++ b/i18n/et.json @@ -0,0 +1,1304 @@ +{ + "about": "Teave", + "account": "Konto", + "account_settings": "Konto seaded", + "acknowledge": "Sain aru", + "action": "Tegevus", + "actions": "Tegevused", + "active": "Aktiivne", + "activity": "Aktiivsus", + "activity_changed": "Aktiivsus on {enabled, select, true {lubatud} other {keelatud}}", + "add": "Lisa", + "add_a_description": "Lisa kirjeldus", + "add_a_location": "Lisa asukoht", + "add_a_name": "Lisa nimi", + "add_a_title": "Lisa pealkiri", + "add_exclusion_pattern": "Lisa välistamismuster", + "add_import_path": "Lisa imporditee", + "add_location": "Lisa asukoht", + "add_more_users": "Lisa rohkem kasutajaid", + "add_partner": "Lisa partner", + "add_path": "Lisa tee", + "add_photos": "Lisa fotosid", + "add_to": "Lisa kohta...", + "add_to_album": "Lisa albumisse", + "add_to_shared_album": "Lisa jagatud albumisse", + "added_to_archive": "Lisatud arhiivi", + "added_to_favorites": "Lisatud lemmikutesse", + "added_to_favorites_count": "{count, number} pilti lisatud lemmikutesse", + "admin": { + "add_exclusion_pattern_description": "Lisa välistamismustreid. Toetatud on metamärgid *, ** ja ?. Kõikide kataloogis nimega \"Raw\" olevate failide ignoreerimiseks kasuta \"**/Raw/**\". Kõikide .tif failide ignoreerimiseks kasuta \"**/*.tif\". Absouutse tee ignoreerimiseks kasuta \"/path/to/ignore/**\".", + "asset_offline_description": "Seda välise kogu üksust ei leitud kettalt ning see liigutati prügikasti. Kui faili asukoht kogu siseselt muutus, leiad vastava uue üksuse oma ajajoonelt. Üksuse taastamiseks veendu, et allpool toodud failitee on Immich'ile kättesaadav ning skaneeri kogu uuesti.", + "authentication_settings": "Autentimise seaded", + "authentication_settings_description": "Halda parooli, OAuth ja muid autentimise seadeid", + "authentication_settings_disable_all": "Kas oled kindel, et soovid kõik sisselogimismeetodid välja lülitada? Sisselogimine lülitatakse täielikult välja.", + "authentication_settings_reenable": "Et taas lubada, kasuta serveri käsku.", + "background_task_job": "Tausttegumid", + "check_all": "Märgi kõik", + "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?", + "confirm_delete_library_assets": "Kas oled kindel, et soovid selle kogu kustutada? Sellega kustutatakse {count, plural, one {# sisalduv üksus} other {kõik # sisalduvat üksust}} Immich'ist ning seda ei saa tagasi võtta. Failid jäävad kettale alles.", + "confirm_email_below": "Kinnitamiseks sisesta allpool \"{email}\"", + "confirm_reprocess_all_faces": "Kas oled kindel, et soovid kõik näod uuesti töödelda? See eemaldab kõik nimega isikud.", + "confirm_user_password_reset": "Kas oled kindel, et soovid kasutaja {user} parooli lähtestada?", + "create_job": "Lisa tööde", + "disable_login": "Keela sisselogimine", + "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et leida sarnaseid pilte. Kasutab nutiotsingut", + "exclusion_pattern_description": "Välistamismustrid võimaldavad ignoreerida faile ja kaustu kogu skaneerimisel. See on kasulik, kui sul on kaustu, mis sisaldavad faile, mida sa ei soovi importida, nagu RAW failid.", + "external_library_created_at": "Väline kogu (lisatud {date})", + "external_library_management": "Väliste kogude haldus", + "face_detection": "Näoavastus", + "face_detection_description": "Avasta üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Värskenda\" töötleb kõik üksused uuesti. \"Lähtesta\" kustutab lisaks kõik seni leitud näed. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Avastatud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", + "facial_recognition_job_description": "Grupeeri avastatud näod inimesteks. See samm käivitub siis, kui näoavastus on lõppenud. \"Lähtesta\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", + "failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}", + "force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik üksused. Seda ei saa tagasi võtta ja faile ei saa taastada.", + "forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine", + "image_format": "Formaat", + "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", + "image_prefer_embedded_preview": "Eelista manustatud eelvaadet", + "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", + "image_prefer_wide_gamut": "Eelista laia värvigammat", + "image_prefer_wide_gamut_setting_description": "Kasuta pisipiltide jaoks Display P3. See säilitab paremini laia värviruumiga piltide erksuse, aga vanematel seadmetel ja vanemate brauseritega võivad pildid teistsugused välja näha. sRGB pildid säilitatakse värvinihete vältimiseks.", + "image_preview_description": "Keskmise suurusega pilt ilma metaandmeteta, kasutusel üksiku üksuse vaatamise ja masinõppe jaoks", + "image_preview_format": "Eelvaate formaat", + "image_preview_quality_description": "Eelvaate kvaliteet vahemikus 1-100. Kõrgem väärtus on parem, aga tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust. Madala väärtuse seadmine võib mõjutada masinõppe kvaliteeti.", + "image_preview_resolution": "Eelvaate resolutsioon", + "image_preview_resolution_description": "Kasutusel üksiku foto vaatamisel ja masinõppe jaoks. Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", + "image_preview_title": "Eelvaate seaded", + "image_quality": "Kvaliteet", + "image_quality_description": "Pildikvaliteet vahemikus 1-100. Kõrgem väärtus tähendab paremat kvaliteeti ja suuremaid faile. See valik mõjutab eelvaateid ja pisipilte.", + "image_resolution": "Resolutsioon", + "image_resolution_description": "Kõrgemad resolutsioonid säilitavad rohkem detaile, aga kodeerimine võtab kauem aega, tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust.", + "image_settings": "Pildi seaded", + "image_settings_description": "Halda genereeritud piltide kvaliteeti ja resolutsiooni", + "image_thumbnail_description": "Väike pisipilt ilma metaandmeteta, kasutusel fotode grupikaupa vaatamisel, näiteks ajajoonel", + "image_thumbnail_format": "Pisipildi formaat", + "image_thumbnail_quality_description": "Pisipildi kvaliteet vahemikus 1-100. Kõrgem väärtus on parem, aga tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust.", + "image_thumbnail_resolution": "Pisipildi resolutsioon", + "image_thumbnail_resolution_description": "Kasutusel fotode mitmekaupa vaatamisel (ajajoon, albumi vaade, jne). Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", + "image_thumbnail_title": "Pisipildi seaded", + "job_concurrency": "{job} samaaegsus", + "job_created": "Tööde lisatud", + "job_not_concurrency_safe": "Seda töödet pole ohutu samaaegselt käivitada.", + "job_settings": "Tööte seaded", + "job_settings_description": "Halda töödete samaaegsust", + "job_status": "Tööte seisund", + "jobs_delayed": "{jobCount, plural, other {# edasi lükatud}}", + "jobs_failed": "{jobCount, plural, other {# ebaõnnestus}}", + "library_created": "Lisatud kogu: {library}", + "library_cron_expression": "Cron avaldis", + "library_cron_expression_description": "Sea skaneerimise intervall cron formaadis. Rohkema info jaoks vaata nt. Crontab Guru", + "library_cron_expression_presets": "Eelseadistatud cron avaldised", + "library_deleted": "Kogu kustutatud", + "library_import_path_description": "Määra kaust, mida importida. Sellest kaustast ning alamkaustadest otsitakse pilte ja videosid.", + "library_scanning": "Perioodiline skaneerimine", + "library_scanning_description": "Seadista kogu perioodiline skaneerimine", + "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_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", + "logging_enable_description": "Luba logimine", + "logging_level_description": "Kui lubatud, millist logimistaset kasutada.", + "logging_settings": "Logimine", + "machine_learning_clip_model": "CLIP mudel", + "machine_learning_clip_model_description": "CLIP mudeli nimi, mis on loetletud siin. Pane tähele, et mudeli muutmisel pead kõigi piltide peal nutiotsingu tööte uuesti käivitama.", + "machine_learning_duplicate_detection": "Duplikaatide leidmine", + "machine_learning_duplicate_detection_enabled": "Luba duplikaatide leidmine", + "machine_learning_duplicate_detection_enabled_description": "Kui keelatud, dedubleeritakse siiski täpselt identsed üksused.", + "machine_learning_duplicate_detection_setting_description": "Kasuta CLIP-manuseid, et leida tõenäoliseid duplikaate", + "machine_learning_enabled": "Luba masinõpe", + "machine_learning_enabled_description": "Kui keelatud, lülitatakse kõik masinõppe funktsioonid välja, sõltumata allolevatest seadetest.", + "machine_learning_facial_recognition": "Näotuvastus", + "machine_learning_facial_recognition_description": "Avasta, tuvasta ja grupeeri piltidel näod", + "machine_learning_facial_recognition_model": "Näotuvastuse mudel", + "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näoavastuse tööde kõigi piltide peal uuesti käivitada.", + "machine_learning_facial_recognition_setting": "Luba näotuvastus", + "machine_learning_facial_recognition_setting_description": "Kui keelatud, siis ei kodeerita pilte näotuvastuse jaoks ning isikute sektsioon Avasta lehel jääb tühjaks.", + "machine_learning_max_detection_distance": "Maksimaalne avastuskaugus", + "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused leiavad rohkem duplikaate, aga võib esineda valepositiivseid.", + "machine_learning_max_recognition_distance": "Maksimaalne tuvastuskaugus", + "machine_learning_max_recognition_distance_description": "Maksimaalne kaugus kahe näo vahel, mida tuleks lugeda samaks isikuks, vahemikus 0-2. Selle vähendamine aitab vältida erinevate inimeste samaks isikuks märkimist ja tõstmine aitab vältida sama inimese kaheks erinevaks isikuks märkimist. Pane tähele, et kaht isikut ühendada on lihtsam kui üht isikut kaheks eraldada, seega võimalusel kasuta madalamat lävendit.", + "machine_learning_min_detection_score": "Minimaalne avastusskoor", + "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo avastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", + "machine_learning_min_recognized_faces": "Minimaalne tuvastatud nägude arv", + "machine_learning_min_recognized_faces_description": "Minimaalne tuvastatud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", + "machine_learning_settings": "Masinõppe seaded", + "machine_learning_settings_description": "Halda masinõppe funktsioone ja seadeid", + "machine_learning_smart_search": "Nutiotsing", + "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", + "manage_concurrency": "Halda samaaegsust", + "manage_log_settings": "Halda logi seadeid", + "map_dark_style": "Tume stiil", + "map_enable_description": "Luba kaardi funktsioonid", + "map_gps_settings": "Kaardi ja GPS-i seaded", + "map_gps_settings_description": "Halda kaardi ja GPS-i (pöördgeokodeerimise) seadeid", + "map_implications": "Kaardifunktsioon kasutab välist kaarditeenust (tiles.immich.cloud)", + "map_light_style": "Hele stiil", + "map_manage_reverse_geocoding_settings": "Halda pöördgeokodeerimise seadeid", + "map_reverse_geocoding": "Pöördgeokodeerimine", + "map_reverse_geocoding_enable_description": "Luba pöördgeokodeerimine", + "map_reverse_geocoding_settings": "Pöördgeokodeerimise seaded", + "map_settings": "Kaart", + "map_settings_description": "Halda kaardi seadeid", + "map_style_description": "Kaarditeema style.json URL", + "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", + "metadata_faces_import_setting_description": "Impordi näod piltide EXIF andmetest ja välistest failidest", + "metadata_settings": "Metaandmete seaded", + "metadata_settings_description": "Halda metaandmete seadeid", + "migration_job": "Migratsioon", + "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", + "no_paths_added": "Ühtegi teed pole", + "no_pattern_added": "Mustreid ei ole", + "note_apply_storage_label_previous_assets": "Märkus: Et rakendada talletussilt varem üleslaaditud üksustele, käivita", + "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", + "note_unlimited_quota": "Märkus: Piiramatu kvoodi jaoks sisesta 0", + "notification_email_from_address": "Saatja aadress", + "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", + "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignoreeri sertifikaadi vigu", + "notification_email_ignore_certificate_errors_description": "Ignoreeri TLS sertifikaadi valideerimise vigu (mittesoovituslik)", + "notification_email_password_description": "Parool e-posti serveriga autentimiseks", + "notification_email_port_description": "E-posti serveri port (nt. 25, 465 või 587)", + "notification_email_sent_test_email_button": "Saada test e-kiri ja salvesta", + "notification_email_setting_description": "E-posti teel teavituste saatmise seaded", + "notification_email_test_email": "Saada test e-kiri", + "notification_email_test_email_failed": "Test e-kirja saatmine ebaõnnestus, kontrolli seadistust", + "notification_email_test_email_sent": "Test e-kiri saadeti aadressile {email}. Kontrolli oma kirjakasti.", + "notification_email_username_description": "Kasutajanimi e-posti serveriga autentimiseks", + "notification_enable_email_notifications": "Luba e-posti teel teavitused", + "notification_settings": "Teavituse seaded", + "notification_settings_description": "Halda teavituste seadeid, sh. e-posti teel", + "oauth_auto_launch": "Automaatne käivitamine", + "oauth_auto_launch_description": "Alusta OAuth autentimist automaatselt sisselogimise lehele jõudmisel", + "oauth_auto_register": "Automaatne registreerimine", + "oauth_auto_register_description": "Registreeri uued kasutajad automaatselt OAuth abil sisselogimisel", + "oauth_button_text": "Nupu tekst", + "oauth_client_id": "Kliendi ID", + "oauth_client_secret": "Kliendi saladus", + "oauth_enable_description": "Sisene OAuth abil", + "oauth_issuer_url": "Väljastaja URL", + "oauth_mobile_redirect_uri": "Mobiilne ümbersuunamise URI", + "oauth_mobile_redirect_uri_override": "Mobiilse ümbersuunamise URI ülekirjutamine", + "oauth_mobile_redirect_uri_override_description": "Lülita sisse, kui OAuth pakkuja ei luba mobiilset URI-d, näiteks '{callback}'", + "oauth_profile_signing_algorithm": "Profiili allkirjastamise algoritm", + "oauth_profile_signing_algorithm_description": "Algoritm, mida kasutatakse kasutajaprofiili allkirjastamiseks.", + "oauth_scope": "Skoop", + "oauth_settings": "OAuth", + "oauth_settings_description": "Halda OAuth sisselogimise seadeid", + "oauth_settings_more_details": "Selle funktsiooni kohta rohkem teada saamiseks loe dokumentatsiooni.", + "oauth_signing_algorithm": "Allkirjastamise algoritm", + "oauth_storage_label_claim": "Talletussildi väide", + "oauth_storage_label_claim_description": "Sea kasutaja talletussildiks automaatselt selle väite väärtus.", + "oauth_storage_quota_claim": "Talletuskvoodi väide", + "oauth_storage_quota_claim_description": "Sea kasutaja talletuskvoodiks automaatselt selle väite väärtus.", + "oauth_storage_quota_default": "Vaikimisi talletuskvoot (GiB)", + "oauth_storage_quota_default_description": "Kvoot (GiB), mida kasutada, kui ühtegi väidet pole esitatud (piiramatu kvoodi jaoks sisesta 0).", + "offline_paths": "Ühenduseta failiteed", + "offline_paths_description": "Need tulemused võivad olla põhjustatud manuaalselt kustutatud failidest, mis ei ole osa välisest kogust.", + "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", + "password_settings": "Parooliga sisselogimine", + "password_settings_description": "Halda parooliga sisselogimise seadeid", + "paths_validated_successfully": "Kõik teed edukalt valideeritud", + "person_cleanup_job": "Isikute korrastamine", + "quota_size_gib": "Kvoot (GiB)", + "refreshing_all_libraries": "Kõikide kogude värskendamine", + "registration": "Administraatori registreerimine", + "registration_description": "Kuna sa oled süsteemis esimene kasutaja, määratakse sind administraatoriks, ning sa saad lisada täiendavaid kasutajaid.", + "repair_all": "Paranda kõik", + "repair_matched_items": "{count, plural, one {# üksus} other {# üksust}} leitud", + "repaired_items": "{count, plural, one {# üksus} other {# üksust}} parandatud", + "require_password_change_on_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", + "reset_settings_to_default": "Lähtesta seaded", + "reset_settings_to_recent_saved": "Taasta hiljuti salvestatud seaded", + "scanning_library": "Kogu skaneerimine", + "scanning_library_for_changed_files": "Kogu muutunud failide skaneerimine", + "scanning_library_for_new_files": "Kogu uute failide skaneerimine", + "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)://", + "server_settings": "Serveri seaded", + "server_settings_description": "Halda serveri seadeid", + "server_welcome_message": "Tervitusteade", + "server_welcome_message_description": "Teade, mida kuvatakse sisselogimise lehel.", + "sidecar_job": "Väliste failide metaandmed", + "sidecar_job_description": "Avasta või sünkroniseeri väliste failide metaandmed failisüsteemist", + "slideshow_duration_description": "Mitu sekundit igat pilti kuvada", + "smart_search_job_description": "Käivita üksuste peal masinõpe, et toetada nutiotsingut", + "storage_template_date_time_description": "Kuupäeva ja kellaaja informatsiooniks kasutatakse üksuse loomise aega", + "storage_template_date_time_sample": "Näidisaeg {date}", + "storage_template_enable_description": "Lülita sisse talletusmallimootor", + "storage_template_hash_verification_enabled": "Räsi kontroll sisse lülitatud", + "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_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.", + "storage_template_path_length": "Tee pikkuse umbkaudne limiit: {length, number}/{limit, number}", + "storage_template_settings": "Talletusmall", + "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", + "storage_template_user_label": "{label} on kasutaja talletussilt", + "system_settings": "Süsteemi seaded", + "tag_cleanup_job": "Siltide korrastamine", + "theme_custom_css_settings": "Kohandatud CSS", + "theme_custom_css_settings_description": "Cascading Style Sheets lubab Immich'i kujunduse kohandamist.", + "theme_settings": "Teema seaded", + "theme_settings_description": "Halda Immich'i veebiliidese kohandamist", + "these_files_matched_by_checksum": "Need failid ühtivad kontrollsumma alusel", + "thumbnail_generation_job": "Pisipiltide genereerimine", + "thumbnail_generation_job_description": "Genereeri iga üksuse kohta suur, väike ja udustatud pisipilt ning iga isiku kohta pisipilt", + "transcoding_acceleration_api": "Kiirenduse API", + "transcoding_acceleration_api_description": "API, mis suhtleb su seadmega transkodeerimise kiirendamiseks. See seadistus on 'anname parima': ebaõnnestumisel kasutatakse tarkvaralist transkodeerimist. VP9 ei pruugi töötada, sõltuvalt riistvarast.", + "transcoding_acceleration_nvenc": "NVENC (vajab NVIDIA GPU-d)", + "transcoding_acceleration_qsv": "Quick Sync (vajab Inteli 7. põlvkonna või uuemat CPU-d)", + "transcoding_acceleration_rkmpp": "RKMPP (ainult Rockchip SOC-d)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Lubatud audiokoodekid", + "transcoding_accepted_audio_codecs_description": "Vali, millised audiokoodekid ei vaja transkodeerimist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_accepted_containers": "Lubatud konteinerid", + "transcoding_accepted_containers_description": "Vali, millised konteineriformaadid ei vaja MP4-ks teisendamist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_accepted_video_codecs": "Lubatud videokoodekid", + "transcoding_accepted_video_codecs_description": "Vali, millised videokoodekid ei vaja transkodeerimist. Kasutusel ainult teatud transkodeerimisreeglite puhul.", + "transcoding_advanced_options_description": "Valikud, mida enamik kasutajaid ei pea muutma", + "transcoding_audio_codec": "Audiokoodek", + "transcoding_audio_codec_description": "Opus on kõrgeima kvaliteediga valik, aga on vähem ühilduv vanade seadmete või tarkvaraga.", + "transcoding_bitrate_description": "Kõrgema kui lubatud bitisagedusega või mittelubatud formaadis videod", + "transcoding_codecs_learn_more": "Siin kasutatud terminoloogia kohta rohkem teada saamiseks loe FFmpeg-i dokumentatsiooni H.264, HEVC ja VP9 koodekite kohta.", + "transcoding_constant_quality_mode": "Püsiva kvaliteedi režiim", + "transcoding_constant_quality_mode_description": "ICQ on parem kui CQP, aga mõned riistvaralise kiirenduse seadmed ei toeta seda režiimi. Selle valiku seadmisel eelistatakse kvaliteedipõhise kodeerimise puhul valitud režiimi. NVENC puhul valikut ignoreeritakse, kuna see ei toeta ICQ-d.", + "transcoding_constant_rate_factor": "Püsiv kiirusefaktor (-crf)", + "transcoding_constant_rate_factor_description": "Video kvaliteeditase. Tüüpilised väärtused on 23 (H.264), 28 (HEVC), 31 (VP9) ning 35 (AV1). Madal on parem, aga tulemuseks on suuremad failid.", + "transcoding_disabled_description": "Ära transkodeeri videosid. Võib takistada taasesitamist mõnedes seadmetes", + "transcoding_hardware_acceleration": "Riistvaraline kiirendus", + "transcoding_hardware_acceleration_description": "Eksperimentaalne; palju kiirem, aga sama bitisageduse juures madalam kvaliteet", + "transcoding_hardware_decoding": "Riistvaraline dekodeerimine", + "transcoding_hardware_decoding_setting_description": "Võimaldab protsessi läbivalt kiirendada, mitte ainult kodeerimist. Ei pruugi kõigi videote puhul töötada.", + "transcoding_hevc_codec": "HEVC koodek", + "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_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", + "transcoding_preferred_hardware_device": "Eelistatud riistvaraseade", + "transcoding_preferred_hardware_device_description": "Rakendub ainult VAAPI ja QSV puhul. Määrab dri seadme, mida kasutatakse riistvaraliseks transkodeerimiseks.", + "transcoding_preset_preset": "Eelseadistus (-preset)", + "transcoding_preset_preset_description": "Pakkimiskiirus. Aeglasemad eelseadistused tekitavad väiksemaid faile ja annavad sama bitisageduse juures parema kvaliteedi. VP9 ignoreerib kiiruseid üle 'faster' taseme.", + "transcoding_reference_frames": "Viitekaadrid", + "transcoding_reference_frames_description": "Kaadrite arv, millele viidata jooksva kaadri pakkimisel. Suuremad väärtused parandavad pakkimise tõhusust, aga muudavad kodeerimise aeglasemaks. 0 määrab väärtuse automaatselt.", + "transcoding_required_description": "Ainult mittelubatud formaadis videod", + "transcoding_settings": "Video transkodeerimise seaded", + "transcoding_settings_description": "Halda videofailide resolutsiooni ja kodeerimist", + "transcoding_target_resolution": "Sihtresolutsioon", + "transcoding_target_resolution_description": "Kõrgemad resolutsioonid säilitavad rohkem detaile, aga kodeerimine võtab kauem aega, tekitab suuremaid faile ning võib mõjutada rakenduse töökiirust.", + "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq_description": "Rakendub NVENC puhul. Parandab paljude detailide, aga vähese liikumisega stseenide kvaliteeti. Ei pruugi ühilduda vanemate seadmetega.", + "transcoding_threads": "Lõimed", + "transcoding_threads_description": "Kõrgem väärtus tähendab kiiremat kodeerimist, aga jätab serverile muude tegevuste jaoks vähem ressursse. See väärtus ei tohiks olla suurem kui protsessori tuumade arv. Väärtus 0 tähendab maksimaalset kasutust.", + "transcoding_tone_mapping": "Toonivastendus", + "transcoding_tone_mapping_description": "Üritab säilitada HDR videote kvaliteeti SDR-iks teisendamisel. Iga algoritm teeb värvi, detailide ja ereduse osas erinevaid kompromisse. Hable säilitab detaile, Mobius säilitab värve ning Reinhard säilitab eredust.", + "transcoding_tone_mapping_npl": "Toonivastendus NPL", + "transcoding_tone_mapping_npl_description": "Muudab värve, et need paistaksid sellise eredusega ekraanil normaalsed. Madalamad väärtused suurendavad video eredust ja vastupidi, kuna see kompenseerib ekraani eredust. 0 määrab väärtuse automaatselt.", + "transcoding_transcode_policy": "Transkodeerimise reegel", + "transcoding_transcode_policy_description": "Reegel, millal tuleks videot transkodeerida. HDR-videosid transkodeeritakse alati (v.a. kui transkodeerimine on keelatud).", + "transcoding_two_pass_encoding": "Kahekäiguline kodeerimine", + "transcoding_two_pass_encoding_setting_description": "Transkodeeri kahes osas, et parandada kodeeritud videote kvaliteeti. Maksimaalse bitisageduse puhul (mis on vajalik H.264 ja HEVC jaoks) kasutab see režiim bitisageduse vahemikku ja ignoreerib CRF-i. VP9 puhul saab kasutada CRF-i, kui maksimaalset bitisagedust pole määratud.", + "transcoding_video_codec": "Videokoodek", + "transcoding_video_codec_description": "VP9 on võimekas ja veebiga ühilduv, aga transkodeerimine võtab kauem aega. HEVC on sarnase jõudluse, aga mitte nii hea veebiga ühilduvusega. H.264 on laialt ühilduv ja transkodeerimine on kiire, aga tulemuseks on suuremad failid. AV1 on kõige võimekam koodek, aga pole vanematel seadmetel toetatud.", + "trash_enabled_description": "Luba prügikast", + "trash_number_of_days": "Päevade arv", + "trash_number_of_days_description": "Päevade arv, kui kaua hoida üksusi prügikastis enne nende lõplikku kustutamist", + "trash_settings": "Prügikasti seaded", + "trash_settings_description": "Halda prügikasti seadeid", + "untracked_files": "Mittejälgitavad failid", + "untracked_files_description": "Rakendus ei jälgi neid faile. Need võivad olla põhjustatud ebaõnnestunud liigutamisest, katkestatud üleslaadimisest või rakenduse veast", + "user_cleanup_job": "Kasutajate korrastamine", + "user_delete_delay": "Kasutaja {user} konto ja üksuste lõplik kustutamine on planeeritud {delay, plural, one {# päeva} other {# päeva}} pärast.", + "user_delete_delay_settings": "Kustutamise viivitus", + "user_delete_delay_settings_description": "Päevade arv, pärast mida kustutatakse eemaldatud kasutaja konto ja üksused jäädavalt. Kasutajate kustutamise tööde käivitub keskööl, et otsida kustutamiseks valmis kasutajaid. Selle seadistuse muudatused rakenduvad järgmisel käivitumisel.", + "user_delete_immediately": "Kasutaja {user} konto ja üksused suunatakse koheselt jäädavale kustutamisele.", + "user_delete_immediately_checkbox": "Suuna kasutaja ja üksused jäädavale kustutamisele", + "user_management": "Kasutajate haldus", + "user_password_has_been_reset": "Kasutaja parool on lähtestatud:", + "user_password_reset_description": "Sisesta kasutajale ajutine parool ja teavita teda, et järgmisel sisselogimisel tuleb parool ära muuta.", + "user_restore_description": "Kasutaja {user} konto taastatakse.", + "user_restore_scheduled_removal": "Taasta kasutaja - eemaldamine planeeritud {date, date, long}", + "user_settings": "Kasutajate seaded", + "user_settings_description": "Halda kasutajate seadeid", + "user_successfully_removed": "Kasutaja {email} on eemaldatud.", + "version_check_enabled_description": "Luba versioonikontroll", + "version_check_implications": "Versioonikontroll vajab perioodilist ühendumist github.com-iga", + "version_check_settings": "Versioonikontroll", + "version_check_settings_description": "Luba/keela uue versiooni teavitus", + "video_conversion_job": "Videote transkodeerimine", + "video_conversion_job_description": "Transkodeeri videod laiema brauserite ja seadmetega ühilduvuse nimel" + }, + "admin_email": "Administraatori e-post", + "admin_password": "Administraatori parool", + "administration": "Administratsioon", + "advanced": "Täpsemad valikud", + "age_months": "Vanus {months, plural, one {# kuu} other {# kuud}}", + "age_year_months": "Vanus 1 aasta, {months, plural, one {# kuu} other {# kuud}}", + "age_years": "{years, plural, other {Vanus #}}", + "album_added": "Album lisatud", + "album_added_notification_setting_description": "Saa teavitus e-posti teel, kui sind lisatakse jagatud albumisse", + "album_cover_updated": "Albumi kaanepilt muudetud", + "album_delete_confirmation": "Kas oled kindel, et soovid albumi {album} kustutada?", + "album_delete_confirmation_description": "Kui see album on jagatud, ei pääse teised kasutajad sellele enam ligi.", + "album_info_updated": "Albumi info muudetud", + "album_leave": "Lahku albumist?", + "album_leave_confirmation": "Kas oled kindel, et soovid albumist {album} lahkuda?", + "album_name": "Albumi nimi", + "album_options": "Albumi valikud", + "album_remove_user": "Eemalda kasutaja?", + "album_remove_user_confirmation": "Kas oled kindel, et soovid kasutaja {user} eemaldada?", + "album_share_no_users": "Paistab, et oled seda albumit kõikide kasutajatega jaganud, või pole ühtegi kasutajat, kellega jagada.", + "album_updated": "Album muudetud", + "album_updated_setting_description": "Saa teavitus e-posti teel, kui jagatud albumis on uusi üksuseid", + "album_user_left": "Lahkutud albumist {album}", + "album_user_removed": "Kasutaja {user} eemaldatud", + "album_with_link_access": "Luba kõigil, kellel on link, näha selle albumi fotosid ja isikuid.", + "albums": "Albumid", + "albums_count": "{count, plural, one {{count, number} album} other {{count, number} albumit}}", + "all": "Kõik", + "all_albums": "Kõik albumid", + "all_people": "Kõik isikud", + "all_videos": "Kõik videod", + "allow_dark_mode": "Luba tume teema", + "allow_edits": "Luba muutmine", + "allow_public_user_to_download": "Luba avalikul kasutajal alla laadida", + "allow_public_user_to_upload": "Luba avalikul kasutajal üles laadida", + "anti_clockwise": "Vastupäeva", + "api_key": "API võti", + "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", + "api_key_empty": "Su API võtme nimi ei tohiks olla tühi", + "api_keys": "API võtmed", + "app_settings": "Rakenduse seaded", + "appears_in": "Kuvatud", + "archive": "Arhiiv", + "archive_or_unarchive_photo": "Arhiveeri või taasta foto", + "archive_size": "Arhiivi suurus", + "archive_size_description": "Seadista arhiivi suurus allalaadimiseks (GiB)", + "archived_count": "{count, plural, other {# arhiveeritud}}", + "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_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_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...", + "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", + "assets_added_to_name_count": "{count, plural, one {# üksus} other {# üksust}} lisatud {hasName, select, true {albumisse {name}} other {uude albumisse}}", + "assets_count": "{count, plural, one {# üksus} other {# üksust}}", + "assets_moved_to_trash_count": "{count, plural, one {# üksus} other {# üksust}} liigutatud prügikasti", + "assets_permanently_deleted_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", + "assets_removed_count": "{count, plural, one {# üksus} other {# üksust}} eemaldatud", + "assets_restore_confirmation": "Kas oled kindel, et soovid oma prügikasti liigutatud üksused taastada? Seda ei saa tagasi võtta! Pane tähele, et sel meetodil ei saa taastada ühenduseta üksuseid.", + "assets_restored_count": "{count, plural, one {# üksus} other {# üksust}} taastatud", + "assets_trashed_count": "{count, plural, one {# üksus} other {# üksust}} liigutatud prügikasti", + "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", + "backward": "Tagasi", + "birthdate_saved": "Sünnikuupäev salvestatud", + "birthdate_set_description": "Sünnikuupäeva kasutatakse isiku vanuse arvutamiseks foto tegemise hetkel.", + "blurred_background": "Udustatud taust", + "bugs_and_feature_requests": "Vearaportid ja täiendussoovid", + "build": "Kooste", + "build_image": "Koostetõmmis", + "bulk_delete_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta!", + "bulk_keep_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} alles jätta? Sellega märgitakse kõik duplikaadigrupid lahendatuks ilma midagi kustutamata.", + "bulk_trash_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid liigutatakse prügikasti.", + "buy": "Osta Immich", + "camera": "Kaamera", + "camera_brand": "Kaamera mark", + "camera_model": "Kaamera mudel", + "cancel": "Katkesta", + "cancel_search": "Katkesta otsing", + "cannot_merge_people": "Ei saa isikuid ühendada", + "cannot_undo_this_action": "Sa ei saa seda tagasi võtta!", + "cannot_update_the_description": "Kirjelduse muutmine ebaõnnestus", + "change_date": "Muuda kuupäeva", + "change_expiration_time": "Muuda aegumisaega", + "change_location": "Muuda asukohta", + "change_name": "Muuda nime", + "change_name_successfully": "Nimi edukalt muudetud", + "change_password": "Parooli muutmine", + "change_password_description": "See on su esimene kord süsteemi siseneda, või on tehtud taotlus parooli muutmiseks. Palun sisesta allpool uus parool.", + "change_your_password": "Muuda oma parooli", + "changed_visibility_successfully": "Nähtavus muudetud", + "check_all": "Märgi kõik", + "check_logs": "Vaata logisid", + "choose_matching_people_to_merge": "Vali kattuvad isikud, mida ühendada", + "city": "Linn", + "clear": "Tühjenda", + "clear_all": "Tühjenda kõik", + "clear_all_recent_searches": "Tühjenda hiljutised otsingud", + "clear_message": "Tühjenda sõnum", + "clear_value": "Tühjenda väärtus", + "clockwise": "Päripäeva", + "close": "Sulge", + "collapse": "Peida", + "collapse_all": "Peida kõik", + "color": "Värv", + "color_theme": "Värviteema", + "comment_deleted": "Kommentaar kustutatud", + "comment_options": "Kommentaari valikud", + "comments_and_likes": "Kommentaarid ja meeldimised", + "comments_are_disabled": "Kommentaarid on keelatud", + "confirm": "Kinnita", + "confirm_admin_password": "Kinnita administraatori parool", + "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", + "confirm_password": "Kinnita parool", + "contain": "Mahuta ära", + "context": "Kontekst", + "continue": "Jätka", + "copied_image_to_clipboard": "Pilt kopeeritud lõikelauale.", + "copied_to_clipboard": "Kopeeritud lõikelauale!", + "copy_error": "Kopeeri viga", + "copy_file_path": "Kopeeri failitee", + "copy_image": "Kopeeri pilt", + "copy_link": "Kopeeri link", + "copy_link_to_clipboard": "Kopeeri link lõikelauale", + "copy_password": "Kopeeri parool", + "copy_to_clipboard": "Kopeeri lõikelauale", + "country": "Riik", + "cover": "Kata kogu ala", + "covers": "Kaanepildid", + "create": "Lisa", + "create_album": "Lisa album", + "create_library": "Lisa kogu", + "create_link": "Lisa link", + "create_link_to_share": "Lisa jagamiseks link", + "create_link_to_share_description": "Luba kõigil, kellel on link, valitud pilte näha", + "create_new_person": "Lisa uus isik", + "create_new_person_hint": "Seosta valitud üksused uue isikuga", + "create_new_user": "Lisa uus kasutaja", + "create_tag": "Lisa silt", + "create_tag_description": "Lisa uus silt. Pesastatud siltide jaoks sisesta täielik tee koos kaldkriipsudega.", + "create_user": "Lisa kasutaja", + "created": "Lisatud", + "current_device": "Praegune seade", + "custom_locale": "Kohandatud lokaat", + "custom_locale_description": "Vorminda kuupäevad ja arvud vastavalt keelele ja regioonile", + "dark": "Tume", + "date_after": "Kuupäev pärast", + "date_and_time": "Kuupäev ja kellaaeg", + "date_before": "Kuupäev enne", + "date_of_birth_saved": "Sünnikuupäev salvestatud", + "date_range": "Kuupäevavahemik", + "day": "Päev", + "deduplicate_all": "Dedubleeri kõik", + "default_locale": "Vaikimisi lokaat", + "default_locale_description": "Vorminda kuupäevad ja numbrid vastavalt brauseri lokaadile", + "delete": "Kustuta", + "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_key": "Kustuta võti", + "delete_library": "Kustuta kogu", + "delete_link": "Kustuta link", + "delete_shared_link": "Kustuta jagatud link", + "delete_tag": "Kustuta silt", + "delete_tag_confirmation_prompt": "Kas oled kindel, et soovid sildi {tagName} kustutada?", + "delete_user": "Kustuta kasutaja", + "deleted_shared_link": "Jagatud link kustutatud", + "deletes_missing_assets": "Kustutab üksused, mis on kettalt puudu", + "description": "Kirjeldus", + "details": "Üksikasjad", + "direction": "Suund", + "disabled": "Välja lülitatud", + "disallow_edits": "Keela muutmine", + "discord": "Discord", + "discover": "Avasta", + "dismiss_all_errors": "Peida kõik veateated", + "dismiss_error": "Peida veateade", + "display_options": "Kuva valikud", + "display_order": "Kuvamise järjekord", + "display_original_photos": "Kuva originaalpildid", + "display_original_photos_setting_description": "Eelista üksuse vaatamisel pisipildile algset fotot, kui see on veebiga ühilduv. See võib mõjutada fotode kuvamise kiirust.", + "do_not_show_again": "Ära näita enam seda teadet", + "documentation": "Dokumentatsioon", + "done": "Tehtud", + "download": "Laadi alla", + "download_include_embedded_motion_videos": "Manustatud videod", + "download_include_embedded_motion_videos_description": "Lisa liikuvatesse fotodesse manustatud videod eraldi failidena", + "download_settings": "Allalaadimine", + "download_settings_description": "Halda üksuste allalaadimise seadeid", + "downloading": "Allalaadimine", + "downloading_asset_filename": "Üksuse {filename} allalaadimine", + "drop_files_to_upload": "Failide üleslaadimiseks sikuta need ükskõik kuhu", + "duplicates": "Duplikaadid", + "duplicates_description": "Lahenda iga grupp, valides duplikaadid, kui neid on", + "duration": "Kestus", + "edit": "Muuda", + "edit_album": "Muuda albumit", + "edit_avatar": "Muuda avatari", + "edit_date": "Muuda kuupäeva", + "edit_date_and_time": "Muuda kuupäeva ja kellaaega", + "edit_exclusion_pattern": "Muuda välistamismustrit", + "edit_faces": "Muuda nägusid", + "edit_import_path": "Muuda imporditeed", + "edit_import_paths": "Muuda imporditeid", + "edit_key": "Muuda võtit", + "edit_link": "Muuda linki", + "edit_location": "Muuda asukohta", + "edit_name": "Muuda nime", + "edit_people": "Muuda isikuid", + "edit_tag": "Muuda silti", + "edit_title": "Muuda pealkirja", + "edit_user": "Muuda kasutajat", + "edited": "Muudetud", + "editor": "Muutja", + "editor_close_without_save_prompt": "Muudatusi ei salvestata", + "editor_close_without_save_title": "Sulge muutja?", + "editor_crop_tool_h2_aspect_ratios": "Kuvasuhted", + "editor_crop_tool_h2_rotation": "Pööre", + "email": "E-post", + "empty_trash": "Tühjenda prügikast", + "empty_trash_confirmation": "Kas oled kindel, et soovid prügikasti tühjendada? See eemaldab kõik seal olevad üksused Immich'ist jäädavalt.\nSeda tegevust ei saa tagasi võtta!", + "enable": "Luba", + "enabled": "Lubatud", + "end_date": "Lõppkuupäev", + "error": "Viga", + "error_loading_image": "Viga pildi laadimisel", + "error_title": "Viga - midagi läks valesti", + "errors": { + "cannot_navigate_next_asset": "Järgmise üksuse juurde liikumine ebaõnnestus", + "cannot_navigate_previous_asset": "Eelmise üksuse juurde liikumine ebaõnnestus", + "cant_apply_changes": "Muudatusi ei õnnestunud rakendada", + "cant_change_activity": "Aktiivsuse {enabled, select, true {keelamine} other {lubamine}} ebaõnnestus", + "cant_change_asset_favorite": "Üksuse lemmiku staatust ei õnnestunud muuta", + "cant_change_metadata_assets_count": "{count, plural, one {# üksuse} other {# üksuse}} metaandmeid ei õnnestunud muuta", + "cant_get_faces": "Nägusid ei õnnestunud kätte saada", + "cant_get_number_of_comments": "Kommentaare ei õnnestunud leida", + "cant_search_people": "Isikuid ei õnnestunud otsida", + "cant_search_places": "Kohti ei õnnestunud otsida", + "cleared_jobs": "Tööted eemaldatud: {job}", + "error_adding_assets_to_album": "Viga üksuste albumisse lisamisel", + "error_adding_users_to_album": "Viga kasutajate albumisse lisamisel", + "error_deleting_shared_user": "Viga jagatud kasutaja kustutamisel", + "error_downloading": "Viga faili {filename} allalaadimisel", + "error_hiding_buy_button": "Viga ostmise nupu peitmisel", + "error_removing_assets_from_album": "Viga üksuste albumist eemaldamisel, rohkem infot leiad konsoolilt", + "error_selecting_all_assets": "Viga kõigi üksuste valimisel", + "exclusion_pattern_already_exists": "See välistamismuster on juba olemas.", + "failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}", + "failed_to_create_album": "Albumi lisamine ebaõnnestus", + "failed_to_create_shared_link": "Jagatud lingi lisamine ebaõnnestus", + "failed_to_edit_shared_link": "Jagatud lingi muutmine ebaõnnestus", + "failed_to_get_people": "Isikute pärimine ebaõnnestus", + "failed_to_load_asset": "Üksuse laadimine ebaõnnestus", + "failed_to_load_assets": "Üksuste laadimine ebaõnnestus", + "failed_to_load_people": "Isikute laadimine ebaõnnestus", + "failed_to_remove_product_key": "Tootevõtme eemaldamine ebaõnnestus", + "failed_to_stack_assets": "Üksuste virnastamine ebaõnnestus", + "failed_to_unstack_assets": "Üksuste eraldamine ebaõnnestus", + "import_path_already_exists": "See imporditee on juba olemas.", + "incorrect_email_or_password": "Vale e-posti aadress või parool", + "paths_validation_failed": "{paths, plural, one {# tee} other {# teed}} ei valideerunud", + "profile_picture_transparent_pixels": "Profiilipildis ei tohi olla läbipaistvaid piksleid. Palun suumi sisse ja/või liiguta pilti.", + "quota_higher_than_disk_size": "Määratud kvoot on suurem kui kettamaht", + "repair_unable_to_check_items": "{count, select, one {Üksuse} other {Üksuste}} kontrollimine ebaõnnestus", + "unable_to_add_album_users": "Kasutajate lisamine albumisse ebaõnnestus", + "unable_to_add_assets_to_shared_link": "Üksuste jagatud lingile lisamine ebaõnnestus", + "unable_to_add_comment": "Kommentaari lisamine ebaõnnestus", + "unable_to_add_exclusion_pattern": "Välistamismustri lisamine ebaõnnestus", + "unable_to_add_import_path": "Imporditee lisamine ebaõnnestus", + "unable_to_add_partners": "Partnerite lisamine ebaõnnestus", + "unable_to_add_remove_archive": "{archived, select, true {Üksuse arhiivist taastamine} other {Üksuse arhiveerimine}} ebaõnnestus", + "unable_to_add_remove_favorites": "Üksuse {favorite, select, true {lemmikuks lisamine} other {lemmikutest eemaldamine}} ebaõnnestus", + "unable_to_archive_unarchive": "{archived, select, true {Arhiveerimine} other {Arhiivist taastamine}} ebaõnnestus", + "unable_to_change_album_user_role": "Kasutaja rolli albumis muutmine ebaõnnestus", + "unable_to_change_date": "Kuupäeva muutmine ebaõnnestus", + "unable_to_change_favorite": "Üksuse lemmiku staatuse muutmine ebaõnnestus", + "unable_to_change_location": "Asukoha muutmine ebaõnnestus", + "unable_to_change_password": "Parooli muutmine ebaõnnestus", + "unable_to_change_visibility": "{count, plural, one {# isiku} other {# isiku}} nähtavuse muutmine ebaõnnestus", + "unable_to_complete_oauth_login": "OAuth sisselogimine ebaõnnestus", + "unable_to_connect": "Ühendumine ebaõnnestus", + "unable_to_connect_to_server": "Serveriga ühendumine ebaõnnestus", + "unable_to_copy_to_clipboard": "Ei saanud kopeerida lõikelauale, kontrolli, kas kasutad lehte üle https-i", + "unable_to_create_admin_account": "Administraatori konto loomine ebaõnnestus", + "unable_to_create_api_key": "Uue API võtme lisamine ebaõnnestus", + "unable_to_create_library": "Kogu lisamine ebaõnnestus", + "unable_to_create_user": "Kasutaja lisamine ebaõnnestus", + "unable_to_delete_album": "Albumi kustutamine ebaõnnestus", + "unable_to_delete_asset": "Üksuse kustutamine ebaõnnestus", + "unable_to_delete_assets": "Viga üksuste kustutamisel", + "unable_to_delete_exclusion_pattern": "Välistamismustri kustutamine ebaõnnestus", + "unable_to_delete_import_path": "Imporditee kustutamine ebaõnnestus", + "unable_to_delete_shared_link": "Jagatud lingi kustutamine ebaõnnestus", + "unable_to_delete_user": "Kasutaja kustutamine ebaõnnestus", + "unable_to_download_files": "Failide allalaadimine ebaõnnestus", + "unable_to_edit_exclusion_pattern": "Välistamismustri muutmine ebaõnnestus", + "unable_to_edit_import_path": "Imporditee muutmine ebaõnnestus", + "unable_to_empty_trash": "Prügikasti tühjendamine ebaõnnestus", + "unable_to_enter_fullscreen": "Täisekraanile lülitamine ebaõnnestus", + "unable_to_exit_fullscreen": "Täisekraanilt väljumine ebaõnnestus", + "unable_to_get_comments_number": "Kommentaaride arvu leidmine ebaõnnestus", + "unable_to_get_shared_link": "Jagamise lingi loomine ebaõnnestus", + "unable_to_hide_person": "Isiku peitmine ebaõnnestus", + "unable_to_link_motion_video": "Liikuva video linkimine ebaõnnestus", + "unable_to_link_oauth_account": "OAuth konto ühendamine ebaõnnestus", + "unable_to_load_album": "Albumi laadimine ebaõnnestus", + "unable_to_load_asset_activity": "Üksuse aktiivsuse laadimine ebaõnnestus", + "unable_to_load_items": "Üksuste laadimine ebaõnnestus", + "unable_to_load_liked_status": "Meeldimise staatuse laadimine ebaõnnestus", + "unable_to_log_out_all_devices": "Kõigist seadmetest väljalogimine ebaõnnestus", + "unable_to_log_out_device": "Seadmest väljalogimine ebaõnnestus", + "unable_to_login_with_oauth": "OAuth abil sisselogimine ebaõnnestus", + "unable_to_play_video": "Video esitamine ebaõnnestus", + "unable_to_reassign_assets_existing_person": "Üksuste {name, select, null {olemasoleva isikuga} other {isikuga {name}}} seostamine ebaõnnestus", + "unable_to_reassign_assets_new_person": "Üksuste uue isikuga seostamine ebaõnnestus", + "unable_to_refresh_user": "Kasutaja värskendamine ebaõnnestus", + "unable_to_remove_album_users": "Kasutajate albumist eemaldamine ebaõnnestus", + "unable_to_remove_api_key": "API võtme eemaldamine ebaõnnestus", + "unable_to_remove_assets_from_shared_link": "Üksuste jagatud lingilt eemaldamine ebaõnnestus", + "unable_to_remove_deleted_assets": "Ühenduseta failide eemaldamine ebaõnnestus", + "unable_to_remove_library": "Kogu eemaldamine ebaõnnestus", + "unable_to_remove_partner": "Partneri eemaldamine ebaõnnestus", + "unable_to_remove_reaction": "Reaktsiooni eemaldamine ebaõnnestus", + "unable_to_repair_items": "Üksuste parandamine ebaõnnestus", + "unable_to_reset_password": "Parooli lähtestamine ebaõnnestus", + "unable_to_resolve_duplicate": "Duplikaadi lahendamine ebaõnnestus", + "unable_to_restore_assets": "Üksuste taastamine ebaõnnestus", + "unable_to_restore_trash": "Prügikastist taastamine ebaõnnestus", + "unable_to_restore_user": "Kasutaja taastamine ebaõnnestus", + "unable_to_save_album": "Albumi salvestamine ebaõnnestus", + "unable_to_save_api_key": "API võtme salvestamine ebaõnnestus", + "unable_to_save_date_of_birth": "Sünnikuupäeva salvestamine ebaõnnestus", + "unable_to_save_name": "Nime salvestamine ebaõnnestus", + "unable_to_save_profile": "Profiili salvestamine ebaõnnestus", + "unable_to_save_settings": "Seadete salvestamine ebaõnnestus", + "unable_to_scan_libraries": "Kogude skaneerimine ebaõnnestus", + "unable_to_scan_library": "Kogu skaneerimine ebaõnnestus", + "unable_to_set_feature_photo": "Esiletõstetud foto seadmine ebaõnnestus", + "unable_to_set_profile_picture": "Profiilipildi seadmine ebaõnnestus", + "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_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", + "unable_to_update_location": "Asukoha muutmine ebaõnnestus", + "unable_to_update_settings": "Seadete muutmine ebaõnnestus", + "unable_to_update_timeline_display_status": "Ajajoonel kuvamise uuendamine ebaõnnestus", + "unable_to_update_user": "Kasutaja muutmine ebaõnnestus", + "unable_to_upload_file": "Faili üleslaadimine ebaõnnestus" + }, + "exif": "Exif", + "exit_slideshow": "Sulge slaidiesitlus", + "expand_all": "Näita kõik", + "expire_after": "Aegub", + "expired": "Aegunud", + "expires_date": "Aegub {date}", + "explore": "Avasta", + "export": "Ekspordi", + "export_as_json": "Ekspordi JSON-formaati", + "extension": "Laiend", + "external": "Väline", + "external_libraries": "Välised kogud", + "face_unassigned": "Seostamata", + "favorite": "Lemmik", + "favorites": "Lemmikud", + "feature_photo_updated": "Esiletõstetud foto muudetud", + "features": "Funktsioonid", + "features_setting_description": "Halda rakenduse funktsioone", + "file_name": "Failinimi", + "file_name_or_extension": "Failinimi või -laiend", + "filename": "Failinimi", + "filetype": "Failitüüp", + "filter_people": "Filtreeri isikuid", + "find_them_fast": "Leia teda kiiresti nime järgi otsides", + "folders": "Kaustad", + "folders_feature_description": "Kaustavaate abil failisüsteemis olevate fotode ja videote sirvimine", + "force_re-scan_library_files": "Sundskaneeri kogu kõik failid uuesti", + "forward": "Edasi", + "general": "Üldine", + "get_help": "Küsi abi", + "getting_started": "Alustamine", + "go_back": "Tagasi", + "go_to_search": "Otsingusse", + "group_albums_by": "Grupeeri albumid...", + "group_no": "Ära grupeeri", + "group_owner": "Grupeeri omaniku kaupa", + "group_year": "Grupeeri aasta kaupa", + "has_quota": "On kvoot", + "hi_user": "Tere {name} ({email})", + "hide_all_people": "Peida kõik isikud", + "hide_gallery": "Peida galerii", + "hide_named_person": "Peida isik {name}", + "hide_password": "Peida parool", + "hide_person": "Peida isik", + "hide_unnamed_people": "Peida nimetud isikud", + "host": "Host", + "hour": "Tund", + "image": "Pilt", + "image_alt_text_date": "{isVideo, select, true {Video} other {Pilt}} tehtud {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikuga {person1}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikutega {person1} ja {person2}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos isikutega {person1}, {person2} ja {person3}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} koos {person1}, {person2} ja veel {additionalCount, number} isikuga", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos isikuga {person1}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos isikutega {person1} ja {person2}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos isikutega {person1}, {person2} ja {person3}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Pilt}} tehtud {date} kohas {city}, {country} koos {person1}, {person2} ja veel {additionalCount, number} isikuga", + "immich_logo": "Immich'i logo", + "immich_web_interface": "Immich'i veebiliides", + "import_from_json": "Impordi JSON-formaadist", + "import_path": "Imporditee", + "in_albums": "{count, plural, one {# albumis} other {# albumis}}", + "in_archive": "Arhiivis", + "include_archived": "Kaasa arhiveeritud", + "include_shared_albums": "Kaasa jagatud albumid", + "include_shared_partner_assets": "Kaasa partneri jagatud üksused", + "individual_share": "Jagatud üksus", + "info": "Info", + "interval": { + "day_at_onepm": "Iga päev kell 13", + "hours": "Iga {hours, plural, one {tunni} other {{hours, number} tunni}} tagant", + "night_at_midnight": "Iga päev keskööl", + "night_at_twoam": "Iga öö kell 2" + }, + "invite_people": "Kutsu inimesi", + "invite_to_album": "Kutsu albumisse", + "items_count": "{count, plural, one {# üksus} other {# üksust}}", + "jobs": "Tööted", + "keep": "Jäta alles", + "keep_all": "Jäta kõik alles", + "keyboard_shortcuts": "Kiirklahvid", + "language": "Keel", + "language_setting_description": "Vali oma eelistatud keel", + "last_seen": "Viimati nähtud", + "latest_version": "Uusim versioon", + "latitude": "Laiuskraad", + "leave": "Lahku", + "let_others_respond": "Luba teistel vastata", + "level": "Tase", + "library": "Kogu", + "library_options": "Kogu seaded", + "light": "Hele", + "like_deleted": "Meeldimine kustutatud", + "link_options": "Lingi valikud", + "link_to_oauth": "Ühenda OAuth", + "linked_oauth_account": "OAuth konto ühendatud", + "list": "Loend", + "loading": "Laadimine", + "loading_search_results_failed": "Otsitulemuste laadimine ebaõnnestus", + "log_out": "Logi välja", + "log_out_all_devices": "Logi kõigist seadmetest välja", + "logged_out_all_devices": "Kõigist seadmetest välja logitud", + "logged_out_device": "Seadmest välja logitud", + "login": "Logi sisse", + "login_has_been_disabled": "Sisselogimine on keelatud.", + "logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?", + "logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?", + "longitude": "Pikkuskraad", + "look": "Välimus", + "loop_videos": "Taasesita videod", + "loop_videos_description": "Lülita sisse, et detailvaates videot automaatselt taasesitada.", + "main_branch_warning": "Sa kasutad arendusversiooni; soovitame tungivalt kasutada väljalaskeversiooni!", + "make": "Mark", + "manage_shared_links": "Halda jagatud linke", + "manage_sharing_with_partners": "Halda partneritega jagamist", + "manage_the_app_settings": "Halda rakenduse seadeid", + "manage_your_account": "Halda oma kontot", + "manage_your_api_keys": "Halda oma API võtmeid", + "manage_your_devices": "Halda oma autenditud seadmeid", + "manage_your_oauth_connection": "Halda oma OAuth ühendust", + "map": "Kaart", + "map_marker_for_images": "Kaardimarker kohas {city}, {country} tehtud piltide jaoks", + "map_marker_with_image": "Kaardimarker pildiga", + "map_settings": "Kaardi seaded", + "matches": "Ühtivad failid", + "media_type": "Meedia tüüp", + "memories": "Mälestused", + "memories_setting_description": "Halda, mida sa oma mälestustes näed", + "memory": "Mälestus", + "menu": "Menüü", + "merge": "Ühenda", + "merge_people": "Ühenda isikud", + "merge_people_limit": "Korraga saab ühendada kuni 5 nägu", + "merge_people_prompt": "Kas soovid need isikud ühendada? Seda tegevust ei saa tagasi võtta.", + "merge_people_successfully": "Isikud ühendatud", + "merged_people_count": "Ühendatud {count, plural, one {# isik} other {# isikut}}", + "minimize": "Minimeeri", + "minute": "Minut", + "missing": "Puuduvad", + "model": "Mudel", + "month": "Kuu", + "more": "Rohkem", + "moved_to_trash": "Liigutatud prügikasti", + "my_albums": "Minu albumid", + "name": "Nimi", + "name_or_nickname": "Nimi või hüüdnimi", + "never": "Mitte kunagi", + "new_album": "Uus album", + "new_api_key": "Uus API võti", + "new_password": "Uus parool", + "new_person": "Uus isik", + "new_user_created": "Uus kasutaja lisatud", + "new_version_available": "UUS VERSIOON SAADAVAL", + "newest_first": "Uuemad eespool", + "next": "Järgmine", + "next_memory": "Järgmine mälestus", + "no": "Ei", + "no_albums_message": "Lisa album fotode ja videote organiseerimiseks", + "no_albums_with_name_yet": "Paistab, et sul pole veel ühtegi selle nimega albumit.", + "no_albums_yet": "Paistab, et sul pole veel ühtegi albumit.", + "no_archived_assets_message": "Arhiveeri fotod ja videod, et neid Fotod vaatest peita", + "no_assets_message": "KLIKI ESIMESE FOTO ÜLESLAADIMISEKS", + "no_duplicates_found": "Ühtegi duplikaati ei leitud.", + "no_exif_info_available": "Exif info pole saadaval", + "no_explore_results_message": "Oma kogu avastamiseks laadi üles rohkem fotosid.", + "no_favorites_message": "Lisa lemmikud, et oma parimaid fotosid ja videosid kiiresti leida", + "no_libraries_message": "Lisa väline kogu oma fotode ja videote vaatamiseks", + "no_name": "Nimetu", + "no_places": "Kohti ei ole", + "no_results": "Vasteid pole", + "no_results_description": "Proovi sünonüümi või üldisemat märksõna", + "no_shared_albums_message": "Lisa album, et fotosid ja videosid teistega jagada", + "not_in_any_album": "Pole üheski albumis", + "note_apply_storage_label_to_previously_uploaded assets": "Märkus: Et rakendada talletussilt varem üleslaaditud üksustele, käivita", + "note_unlimited_quota": "Märkus: Piiramatu kvoodi jaoks sisesta 0", + "notes": "Märkused", + "notification_toggle_setting_description": "Luba e-posti teel teavitused", + "notifications": "Teavitused", + "notifications_setting_description": "Halda teavitusi", + "oauth": "OAuth", + "official_immich_resources": "Ametlikud Immich'i ressursid", + "offline": "Ühendus puudub", + "offline_paths": "Ühenduseta failiteed", + "offline_paths_description": "Need tulemused võivad olla põhjustatud manuaalselt kustutatud failidest, mis ei ole osa välisest kogust.", + "ok": "Ok", + "oldest_first": "Vanemad eespool", + "onboarding": "Kasutuselevõtt", + "onboarding_privacy_description": "Järgnevad (valikulised) funktsioonid sõltuvad välistest teenustest ning neid saab igal ajal administraatori seadetes välja lülitada.", + "onboarding_theme_description": "Vali oma serverile värviteema. Saad seda hiljem seadetes muuta.", + "onboarding_welcome_description": "Algväärtustame mõned levinumad seaded.", + "onboarding_welcome_user": "Tere tulemast, {user}", + "online": "Ühendatud", + "only_favorites": "Ainult lemmikud", + "only_refreshes_modified_files": "Värskendab ainult muudetud failid", + "open_in_map_view": "Ava kaardi vaates", + "open_in_openstreetmap": "Ava OpenStreetMap", + "open_the_search_filters": "Ava otsingufiltrid", + "options": "Valikud", + "or": "või", + "organize_your_library": "Korrasta oma kogu", + "original": "originaal", + "other": "Muud", + "other_devices": "Muud seadmed", + "other_variables": "Muud muutujad", + "owned": "Minu omad", + "owner": "Omanik", + "partner": "Partner", + "partner_can_access": "{partner} pääseb ligi", + "partner_can_access_assets": "Kõik su fotod ja videod, välja arvatud arhiveeritud ja kustutatud", + "partner_can_access_location": "Asukohad, kus su fotod tehti", + "partner_sharing": "Partneriga jagamine", + "partners": "Partnerid", + "password": "Parool", + "password_does_not_match": "Parool ei klapi", + "password_required": "Parool on nõutud", + "password_reset_success": "Parooli lähtestamine õnnestus", + "past_durations": { + "days": "{days, plural, one {Viimane päev} other {Viimased # päeva}}", + "hours": "{hours, plural, one {Viimane tund} other {Viimased # tundi}}", + "years": "{years, plural, one {Viimane aasta} other {Viimased # aastat}}" + }, + "path": "Tee", + "pattern": "Muster", + "pause": "Peata", + "pause_memories": "Peata mälestused", + "paused": "Peatatud", + "pending": "Ootel", + "people": "Isikud", + "people_edits_count": "{count, plural, one {# isik} other {# isikut}} muudetud", + "people_feature_description": "Fotode ja videote sirvimine inimeste kaupa grupeeritult", + "people_sidebar_description": "Kuva külgmenüüs Isikute link", + "permanent_deletion_warning": "Jäädavalt kustutamise hoiatus", + "permanent_deletion_warning_setting_description": "Kuva hoiatust üksuste jäädaval kustutamisel", + "permanently_delete": "Kustuta jäädavalt", + "permanently_delete_assets_count": "Kustuta {count, plural, one {üksus} other {üksused}} jäädavalt", + "permanently_delete_assets_prompt": "Kas oled kindel, et soovid {count, plural, one {selle üksuse} other {need # üksust}} jäädavalt kustutada? Sellega eemaldatakse {count, plural, one {see} other {need}} ka oma albumi(te)st.", + "permanently_deleted_asset": "Üksus jäädavalt kustutatud", + "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", + "person": "Isik", + "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", + "photos_and_videos": "Fotod ja videod", + "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotot}}", + "photos_from_previous_years": "Fotod varasematest aastatest", + "pick_a_location": "Vali asukoht", + "place": "Asukoht", + "places": "Kohad", + "play": "Esita", + "play_memories": "Esita mälestused", + "play_motion_photo": "Esita liikuv foto", + "play_or_pause_video": "Esita või peata video", + "port": "Port", + "preset": "Eelseadistus", + "preview": "Eelvaade", + "previous": "Eelmine", + "previous_memory": "Eelmine mälestus", + "previous_or_next_photo": "Eelmine või järgmine foto", + "primary": "Peamine", + "privacy": "Privaatsus", + "profile_image_of_user": "Kasutaja {user} profiilipilt", + "profile_picture_set": "Profiilipilt määratud.", + "public_album": "Avalik album", + "purchase_account_info": "Toetaja", + "purchase_activated_subtitle": "Aitäh, et toetad Immich'it ja avatud lähtekoodiga tarkvara", + "purchase_activated_time": "Aktiveeritud {date, date}", + "purchase_activated_title": "Sinu võtme aktiveerimine õnnestus", + "purchase_button_activate": "Aktiveeri", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Ära näita enam", + "purchase_button_reminder": "Tuleta mulle 30 päeva pärast meelde", + "purchase_button_remove_key": "Eemalda võti", + "purchase_button_select": "Vali", + "purchase_failed_activation": "Aktiveerimine ebaõnnestus! Kontrolli oma kirjakastist õiget tootevõtit!", + "purchase_individual_description_1": "Üksikisikule", + "purchase_individual_description_2": "Toetaja staatus", + "purchase_individual_title": "Individuaalne", + "purchase_input_suggestion": "Sul on juba tootevõti? Sisesta see allpool", + "purchase_license_subtitle": "Osta Immich, et toetada selle jätkuvat arendust", + "purchase_lifetime_description": "Eluaegne ost", + "purchase_option_title": "OSTMISE VALIKUD", + "purchase_panel_info_1": "Immich'i arendamine nõuab palju aega ja vaeva ning meie täiskohaga insenerid töötavad selle nimel, et teha see nii heaks kui vähegi võimalik. Meie missiooniks on muuta avatud lähtekoodiga tarkvara ja eetilised äritavad arendajatele jätkusuutlikuks sissetulekuallikaks ning luua privaatsust austav ökosüsteem, mis pakub tõelisi alternatiive ekspluatatiivsetele pilveteenustele.", + "purchase_panel_info_2": "Kuna oleme otsustanud maksumüüre mitte lisada, ei anna see ost sulle Immich'is lisavõimalusi. Me loodame Immich'i jätkuvaks arenduseks sinusuguste kasutajate toetusele.", + "purchase_panel_title": "Toeta projekti", + "purchase_per_server": "Serveri kohta", + "purchase_per_user": "Kasutaja kohta", + "purchase_remove_product_key": "Eemalda tootevõti", + "purchase_remove_product_key_prompt": "Kas oled kindel, et soovid tootevõtme eemaldada?", + "purchase_remove_server_product_key": "Eemalda serveri tootevõti", + "purchase_remove_server_product_key_prompt": "Kas oled kindel, et soovid serveri tootevõtme eemaldada?", + "purchase_server_description_1": "Kogu serveri jaoks", + "purchase_server_description_2": "Toetaja staatus", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Serveri tootevõtit haldab administraator", + "rating": "Hinnang", + "rating_clear": "Tühjenda hinnang", + "rating_count": "{count, plural, one {# tärn} other {# tärni}}", + "rating_description": "Kuva infopaneelis EXIF hinnangut", + "reaction_options": "Reaktsiooni valikud", + "read_changelog": "Vaata muudatuste ülevaadet", + "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", + "recent_searches": "Hiljutised otsingud", + "refresh": "Värskenda", + "refresh_encoded_videos": "Värskenda kodeeritud videod", + "refresh_faces": "Värskenda näod", + "refresh_metadata": "Värskenda metaandmed", + "refresh_thumbnails": "Värskenda pisipildid", + "refreshed": "Värskendatud", + "refreshes_every_file": "Loeb kõik olemasolevad ja uued failid uuesti", + "refreshing_encoded_video": "Kodeeritud videote värskendamine", + "refreshing_faces": "Nägude värskendamine", + "refreshing_metadata": "Metaandmete värskendamine", + "regenerating_thumbnails": "Pisipiltide uuesti genereerimine", + "remove": "Eemalda", + "remove_assets_album_confirmation": "Kas oled kindel, et soovid {count, plural, one {# üksuse} other {# üksust}} albumist eemaldada?", + "remove_assets_shared_link_confirmation": "Kas oled kindel, et soovid eemaldada {count, plural, one {# üksuse} other {# üksust}} sellelt jagatud lingilt?", + "remove_assets_title": "Eemalda üksused?", + "remove_custom_date_range": "Eemalda kohandatud kuupäevavahemik", + "remove_deleted_assets": "Eemalda kustutatud üksused", + "remove_from_album": "Eemalda albumist", + "remove_from_favorites": "Eemalda lemmikutest", + "remove_from_shared_link": "Eemalda jagatud lingist", + "remove_user": "Eemalda kasutaja", + "removed_api_key": "API võti eemaldatud: {name}", + "removed_from_archive": "Arhiivist eemaldatud", + "removed_from_favorites": "Lemmikutest eemaldatud", + "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", + "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", + "rename": "Nimeta ümber", + "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", + "reset": "Lähtesta", + "reset_password": "Lähtesta parool", + "reset_people_visibility": "Lähtesta isikute nähtavus", + "reset_to_default": "Lähtesta", + "resolve_duplicates": "Lahenda duplikaadid", + "resolved_all_duplicates": "Kõik duplikaadid lahendatud", + "restore": "Taasta", + "restore_all": "Taasta kõik", + "restore_user": "Taasta kasutaja", + "restored_asset": "Üksus taastatud", + "resume": "Jätka", + "retry_upload": "Proovi üleslaadimist uuesti", + "review_duplicates": "Vaata duplikaadid läbi", + "role": "Roll", + "role_editor": "Muutja", + "role_viewer": "Vaataja", + "save": "Salvesta", + "saved_api_key": "API võti salvestatud", + "saved_profile": "Profiil salvestatud", + "saved_settings": "Seaded salvestatud", + "say_something": "Ütle midagi", + "scan_all_libraries": "Skaneeri kõik kogud", + "scan_all_library_files": "Skaneeri kogu kõik failid uuesti", + "scan_library": "Skaneeri", + "scan_new_library_files": "Skaneeri kogu uued failid", + "scan_settings": "Skaneerimise seaded", + "scanning_for_album": "Albumi skaneerimine...", + "search": "Otsi", + "search_albums": "Otsi albumeid", + "search_by_context": "Otsi konteksti alusel", + "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_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_settings": "Otsi seadeid", + "search_state": "Otsi osariiki...", + "search_tags": "Otsi silte...", + "search_timezone": "Otsi ajavööndit...", + "search_type": "Otsingu tüüp", + "search_your_photos": "Otsi oma fotosid", + "searching_locales": "Lokaatide otsimine...", + "second": "Sekund", + "see_all_people": "Vaata kõiki isikuid", + "select_album_cover": "Vali albumi kaanepilt", + "select_all": "Vali kõik", + "select_all_duplicates": "Vali kõik duplikaadid", + "select_avatar_color": "Vali avatari värv", + "select_face": "Vali nägu", + "select_featured_photo": "Vali esiletõstetud foto", + "select_from_computer": "Vali arvutist", + "select_library_owner": "Vali kogu omanik", + "select_new_face": "Vali uus nägu", + "select_photos": "Vali fotod", + "selected": "Valitud", + "selected_count": "{count, plural, other {# valitud}}", + "send_message": "Saada sõnum", + "send_welcome_email": "Saada tervituskiri", + "server_offline": "Serveriga ühendus puudub", + "server_online": "Server ühendatud", + "server_stats": "Serveri statistika", + "server_version": "Serveri versioon", + "set_as_album_cover": "Sea albumi kaanepildiks", + "set_as_profile_picture": "Sea profiilipildiks", + "set_date_of_birth": "Määra sünnikuupäev", + "set_profile_picture": "Sea profiilipilt", + "set_slideshow_to_fullscreen": "Kuva slaidiesitlus täisekraanil", + "settings": "Seaded", + "settings_saved": "Seaded salvestatud", + "share": "Jaga", + "shared": "Jagatud", + "shared_by": "Jagas", + "shared_by_user": "Jagas {user}", + "shared_by_you": "Jagasid sina", + "shared_from_partner": "Fotod partnerilt {partner}", + "shared_link_options": "Jagatud lingi valikud", + "shared_links": "Jagatud lingid", + "shared_photos_and_videos_count": "{assetCount, plural, other {# jagatud fotot ja videot.}}", + "shared_with_partner": "Jagatud partneriga {partner}", + "sharing": "Jagamine", + "sharing_enter_password": "Palun sisesta selle lehe vaatamiseks salasõna.", + "sharing_sidebar_description": "Kuva külgmenüüs Jagamise linki", + "shift_to_permanent_delete": "vajuta ⇧, et üksus jäädavalt kustutada", + "show_album_options": "Näita albumi valikuid", + "show_albums": "Näita albumeid", + "show_all_people": "Näita kõiki isikuid", + "show_and_hide_people": "Näita ja peida isikuid", + "show_file_location": "Näita faili asukohta", + "show_gallery": "Näita galeriid", + "show_hidden_people": "Kuva peidetud inimesed", + "show_in_timeline": "Näita ajajoonel", + "show_in_timeline_setting_description": "Kuva oma ajajoonel selle kasutaja fotosid ja videosid", + "show_keyboard_shortcuts": "Kuva kiirklahvid", + "show_metadata": "Kuva metaandmed", + "show_or_hide_info": "Kuva või peida info", + "show_password": "Kuva parooli", + "show_person_options": "Näita isiku valikuid", + "show_progress_bar": "Kuva edenemisriba", + "show_search_options": "Kuva otsingu valikud", + "show_slideshow_transition": "Kuva slaidiesitluse üleminekud", + "show_supporter_badge": "Toetaja märk", + "show_supporter_badge_description": "Kuva toetaja märki", + "shuffle": "Juhuslik", + "sidebar": "Külgmenüü", + "sidebar_display_description": "Kuva külgmenüüs linki vaatele", + "sign_out": "Logi välja", + "sign_up": "Registreeru", + "size": "Suurus", + "skip_to_content": "Sisu juurde", + "skip_to_folders": "Kaustade juurde", + "skip_to_tags": "Siltide juurde", + "slideshow": "Slaidiesitlus", + "slideshow_settings": "Slaidiesitluse seaded", + "sort_albums_by": "Järjesta albumid...", + "sort_created": "Loomise aeg", + "sort_items": "Üksuste arv", + "sort_modified": "Muutmise aeg", + "sort_oldest": "Vanim foto", + "sort_recent": "Uusim foto", + "sort_title": "Pealkiri", + "source": "Lähtekood", + "stack": "Virnasta", + "stack_duplicates": "Virnasta duplikaadid", + "stack_select_one_photo": "Vali virnale kaanefoto", + "stack_selected_photos": "Virnasta valitud fotod", + "stacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} virnastatud", + "stacktrace": "Pinujälg", + "start": "Alusta", + "start_date": "Alguskuupäev", + "state": "Osariik", + "status": "Staatus", + "stop_motion_photo": "Peata liikuv pilt", + "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", + "suggestions": "Soovitused", + "sunrise_on_the_beach": "Päikesetõus rannal", + "support": "Tugi", + "support_and_feedback": "Tugi ja tagasiside", + "support_third_party_description": "Sinu Immich'i install on kolmanda osapoole pakendatud. Probleemid, mida täheldad, võivad olla põhjustatud selle pakendamise poolt, seega võta esmajärjekorras nendega ühendust, kasutades allolevaid linke.", + "swap_merge_direction": "Muuda ühendamise suunda", + "sync": "Sünkrooni", + "tag": "Silt", + "tag_assets": "Sildista üksuseid", + "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_updated": "Muudetud silt: {tag}", + "tagged_assets": "{count, plural, one {# üksus} other {# üksust}} sildistatud", + "tags": "Sildid", + "template": "Mall", + "theme": "Teema", + "theme_selection": "Teema valik", + "theme_selection_description": "Sea automaatselt hele või tume teema vastavalt veebilehitseja eelistustele", + "they_will_be_merged_together": "Nad ühendatakse kokku", + "third_party_resources": "Kolmanda osapoole ressursid", + "time_based_memories": "Ajapõhised mälestused", + "timezone": "Ajavöönd", + "to_archive": "Arhiivi", + "to_change_password": "Muuda parool", + "to_favorite": "Lemmik", + "to_trash": "Prügikasti", + "toggle_settings": "Kuva/peida seaded", + "toggle_theme": "Lülita tume teema", + "total_usage": "Kogukasutus", + "trash": "Prügikast", + "trash_all": "Kõik prügikasti", + "trash_count": "Liiguta {count, number} prügikasti", + "trash_delete_asset": "Kustuta üksus", + "trash_no_results_message": "Siia ilmuvad prügikasti liigutatud fotod ja videod.", + "trashed_items_will_be_permanently_deleted_after": "Prügikasti tõstetud üksused kustutatakse jäädavalt {days, plural, one {# päeva} other {# päeva}} pärast.", + "type": "Tüüp", + "unarchive": "Taasta arhiivist", + "unarchived_count": "{count, plural, other {# arhiivist taastatud}}", + "unfavorite": "Eemalda lemmikutest", + "unhide_person": "Ära peida isikut", + "unknown": "Teadmata", + "unknown_year": "Teadmata aasta", + "unlimited": "Piiramatu", + "unlink_oauth": "Eemalda OAuth ühendus", + "unlinked_oauth_account": "OAuth ühendus eemaldatud", + "unnamed_album": "Nimetu album", + "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", + "unsaved_change": "Salvestamata muudatus", + "unstack": "Eralda", + "unstacked_assets_count": "{count, plural, one {# üksus} other {# üksust}} eraldatud", + "untracked_files": "Mittejälgitavad failid", + "untracked_files_decription": "Rakendus ei jälgi neid faile. Need võivad olla põhjustatud ebaõnnestunud liigutamisest, katkestatud üleslaadimisest või rakenduse veast", + "up_next": "Järgmine", + "updated_password": "Parool muudetud", + "upload": "Laadi üles", + "upload_concurrency": "Üleslaadimise samaaegsus", + "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", + "upload_progress": "Ootel {remaining, number} - Töödeldud {processed, number}/{total, number}", + "upload_skipped_duplicates": "{count, plural, one {# dubleeritud üksus} other {# dubleeritud üksust}} vahele jäetud", + "upload_status_duplicates": "Duplikaadid", + "upload_status_errors": "Vead", + "upload_status_uploaded": "Üleslaaditud", + "upload_success": "Üleslaadimine õnnestus, uute üksuste nägemiseks värskenda lehte.", + "url": "URL", + "usage": "Kasutus", + "use_custom_date_range": "Kasuta kohandatud kuupäevavahemikku", + "user": "Kasutaja", + "user_id": "Kasutaja ID", + "user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}", + "user_purchase_settings": "Ost", + "user_purchase_settings_description": "Halda oma ostu", + "user_role_set": "Määra kasutajale {user} roll {role}", + "user_usage_detail": "Kasutajate kasutusandmed", + "username": "Kasutajanimi", + "users": "Kasutajad", + "utilities": "Tööriistad", + "validate": "Valideeri", + "variables": "Muutujad", + "version": "Versioon", + "version_announcement_closing": "Sinu sõber, Alex", + "version_announcement_message": "Hei sõber, saadaval on rakenduse uus versioon. Palun võta aega, et lugeda väljalasketeadet ning veendu, et su docker-compose.yml ja .env failid on ajakohased, et vältida konfiguratsiooniprobleeme, eriti kui kasutad WatchTower'it või muud mehhanismi, mis rakendust automaatselt uuendab.", + "version_history": "Versiooniajalugu", + "version_history_item": "Versioon {version} paigaldatud {date}", + "video": "Video", + "video_hover_setting": "Esita hõljutamisel video eelvaade", + "video_hover_setting_description": "Esita video eelvaade, kui hiirt selle kohal hõljutada. Isegi kui keelatud, saab taasesituse alustada taasesitusnupu kohal hõljutades.", + "videos": "Videod", + "videos_count": "{count, plural, one {# video} other {# videot}}", + "view": "Vaade", + "view_album": "Vaata albumit", + "view_all": "Vaata kõiki", + "view_all_users": "Vaata kõiki kasutajaid", + "view_in_timeline": "Vaata ajajoonel", + "view_links": "Vaata linke", + "view_next_asset": "Vaata järgmist üksust", + "view_previous_asset": "Vaata eelmist üksust", + "view_stack": "Vaata virna", + "visibility_changed": "{count, plural, one {# isiku} other {# isiku}} nähtavus muudetud", + "waiting": "Ootel", + "warning": "Hoiatus", + "week": "Nädal", + "welcome": "Tere tulemast", + "welcome_to_immich": "Tere tulemast Immich'isse", + "year": "Aasta", + "years_ago": "{years, plural, one {# aasta} other {# aastat}} tagasi", + "yes": "Jah", + "you_dont_have_any_shared_links": "Sul pole ühtegi jagatud linki", + "zoom_image": "Suumi pilti" +} diff --git a/web/src/lib/i18n/fa.json b/i18n/fa.json similarity index 68% rename from web/src/lib/i18n/fa.json rename to i18n/fa.json index f410cfb14e..7c0fba9f35 100644 --- a/web/src/lib/i18n/fa.json +++ b/i18n/fa.json @@ -144,7 +144,7 @@ "note_cannot_be_changed_later": "توجه: این را نمی توان بعداً تغییر داد!", "note_unlimited_quota": "توجه: برای سهمیه نامحدود، عدد 0 را وارد کنید", "notification_email_from_address": "آدرس فرستنده", - "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", + "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", "notification_email_host_description": "میزبان سرور ایمیل (مثلاً smtp.immich.app)", "notification_email_ignore_certificate_errors": "خطاهای گواهی را نادیده بگیر", "notification_email_ignore_certificate_errors_description": "خطاهای اعتبارسنجی گواهی TLS را نادیده بگیر (توصیه نمی‌شود)", @@ -169,7 +169,7 @@ "oauth_enable_description": "ورود توسط OAuth", "oauth_issuer_url": "نشانی وب صادر کننده", "oauth_mobile_redirect_uri": "تغییر مسیر URI موبایل", - "oauth_mobile_redirect_uri_override": "", + "oauth_mobile_redirect_uri_override": "تغییر مسیر URI تلفن همراه", "oauth_mobile_redirect_uri_override_description": "زمانی که 'app.immich:/' یک URI پرش نامعتبر است، فعال کنید.", "oauth_profile_signing_algorithm": "الگوریتم امضای پروفایل", "oauth_profile_signing_algorithm_description": "الگوریتم مورد استفاده برای امضای پروفایل کاربر.", @@ -194,7 +194,7 @@ "refreshing_all_libraries": "بروز رسانی همه کتابخانه ها", "registration": "ثبت نام مدیر", "registration_description": "از آنجایی که شما اولین کاربر در سیستم هستید، به عنوان مدیر تعیین شده‌اید و مسئولیت انجام وظایف مدیریتی بر عهده شما خواهد بود و کاربران اضافی توسط شما ایجاد خواهند شد.", - "removing_offline_files": "حذف فایل‌های آفلاین", + "removing_deleted_files": "حذف فایل‌های آفلاین", "repair_all": "بازسازی همه", "repair_matched_items": "", "repaired_items": "", @@ -210,116 +210,123 @@ "server_settings_description": "مدیریت تنظیمات سرور", "server_welcome_message": "پیام خوش آمد گویی", "server_welcome_message_description": "پیامی که در صفحه ورود به سیستم نمایش داده می شود.", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", + "sidecar_job": "اطلاعات جانبی", + "sidecar_job_description": "یافتن یا همگام‌سازی اطلاعات جانبی از فایل سیستم", + "slideshow_duration_description": "زمان ( به ثانیه ) نشان دادن هر عکس", + "smart_search_job_description": "اجرای یادگیری ماشینی بر روی دارایی‌ها برای پشتیبانی از جستجوی هوشمند", + "storage_template_date_time_description": "زمان‌بندی ایجاد دارایی برای اطلاعات تاریخ و زمان استفاده می‌شود", + "storage_template_date_time_sample": "نمونه زمان {date}", "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_description": "", - "storage_template_migration_info": "", - "storage_template_migration_job": "", - "storage_template_more_details": "", - "storage_template_onboarding_description": "", - "storage_template_path_length": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "storage_template_user_label": "", - "system_settings": "", - "theme_custom_css_settings": "", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "these_files_matched_by_checksum": "", - "thumbnail_generation_job": "", - "thumbnail_generation_job_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", - "transcoding_accepted_audio_codecs": "", - "transcoding_accepted_audio_codecs_description": "", - "transcoding_accepted_video_codecs": "", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_codecs_learn_more": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", + "storage_template_hash_verification_enabled": "تأیید هَش فعال شد", + "storage_template_hash_verification_enabled_description": "تأیید هَش را فعال می‌کند؛ این گزینه را غیرفعال نکنید مگر اینکه از عواقب آن مطمئن باشید", + "storage_template_migration": "انتقال الگوی ذخیره سازی", + "storage_template_migration_description": "قالب فعلی {template} را به دارایی‌های بارگذاری شده قبلی اعمال کنید", + "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_user_label": "{label} برچسب ذخیره‌سازی کاربر است", + "system_settings": "تنظیمات سیستم", + "theme_custom_css_settings": "CSS سفارشی", + "theme_custom_css_settings_description": "برگه‌های سبک آبشاری (CSS) امکان سفارشی‌سازی طراحی Immich را فراهم می‌کنند.", + "theme_settings": "تنظیمات پوسته", + "theme_settings_description": "مدیریت سفارشی‌سازی رابط کاربری وب Immich", + "these_files_matched_by_checksum": "این فایل‌ها با استفاده از چک‌سام‌هایشان مطابقت دارند", + "thumbnail_generation_job": "ایجاد تصاویر بندانگشتی", + "thumbnail_generation_job_description": "ایجاد تصاویر بندانگشتی بزرگ، کوچک و تار برای هر دارایی، همچنین تصاویر بندانگشتی برای هر فرد", + "transcoding_acceleration_api": "API شتاب‌دهنده", + "transcoding_acceleration_api_description": "API که با دستگاه شما تعامل خواهد داشت تا فرایند تبدیل (ترنسکودینگ) را تسریع کند. این تنظیم به‌صورت «بهترین تلاش» عمل می‌کند: در صورت شکست، به تبدیل نرم‌افزاری بازمی‌گردد. عملکرد VP9 بسته به سخت‌افزار شما ممکن است کار کند یا نکند.", + "transcoding_acceleration_nvenc": "NVENC ( کارت گرافیک NVIDIA لازم است )", + "transcoding_acceleration_qsv": "همگام سازی سریع (نیاز به پردازنده اینتل نسل هفتم یا بالاتر)", + "transcoding_acceleration_rkmpp": "RKMPP (فقط بر روی Rockchip SOCs)", + "transcoding_acceleration_vaapi": "VAAPI", + "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_advanced_options_description": "گزینه‌هایی که بیشتر کاربران نیازی به تغییر آن‌ها ندارند", + "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_rate_factor": "ضریب نرخ ثابت ( crf- )", + "transcoding_constant_rate_factor_description": "سطح کیفیت ویدیو. هرچه عدد کمتر باشد، کیفیت بهتر است، اما فایل‌های بزرگ‌تری تولید می‌کند. مقادیر معمول عبارتند از: (23 <-- H.264) - (28 --> HEVC) - (31 --> VP9) - (35 --> AV1).", + "transcoding_disabled_description": "هیچ ویدیویی را تبدیل فرمت نکنید، زیرا ممکن است پخش در برخی از کلاینت‌ها را مختل کند", + "transcoding_hardware_acceleration": "شتاب دهنده سخت افزاری", + "transcoding_hardware_acceleration_description": "آزمایشی؛ بسیار سریع‌تر است، اما در همان بیت‌ریت کیفیت کمتری خواهد داشت", + "transcoding_hardware_decoding": "رمزگشایی سخت افزاری", "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", + "transcoding_hevc_codec": "کدک HEVC", + "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_keyframe_interval": "حداکثر فاصله کلید فریم", + "transcoding_max_keyframe_interval_description": "حداکثر فاصله فریم بین کلیدفریم‌ها را تنظیم می‌کند. مقادیر پایین‌تر کارایی فشرده‌سازی را کاهش می‌دهند، اما زمان جستجو را بهبود می‌بخشند و ممکن است کیفیت را در صحنه‌های با حرکت سریع بهبود دهند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_optimal_description": "ویدیوهایی که از رزولوشن هدف بالاتر هستند یا در قالب پذیرفته شده نیستند", + "transcoding_preferred_hardware_device": "دستگاه سخت‌افزاری ترجیحی", + "transcoding_preferred_hardware_device_description": "این گزینه فقط به VAAPI و QSV اعمال می‌شود. DRI node مورد استفاده برای تبدیل فرمت سخت‌افزاری را تنظیم می‌کند.", + "transcoding_preset_preset": "پیش‌تنظیم (preset-)", + "transcoding_preset_preset_description": "سرعت فشرده‌سازی. پیش‌تنظیم‌های کندتر فایل‌های کوچک‌تری تولید می‌کنند و کیفیت را هنگام هدف‌گذاری بر روی یک بیت‌ریت خاص افزایش می‌دهند. VP9 سرعت‌های بالاتر از 'faster' را نادیده می‌گیرد.", + "transcoding_reference_frames": "فریم‌های مرجع", + "transcoding_reference_frames_description": "تعداد فریم‌هایی که هنگام فشرده‌سازی یک فریم مشخص به آن‌ها ارجاع داده می‌شود. مقادیر بالاتر کارایی فشرده‌سازی را بهبود می‌بخشند، اما کدگذاری را کندتر می‌کنند. مقدار 0 این مقدار را به‌طور خودکار تنظیم می‌کند.", + "transcoding_required_description": "فقط ویدیوهایی که در فرمت پذیرفته‌شده نیستند", + "transcoding_settings": "تنظیمات تبدیل ویدیو", + "transcoding_settings_description": "مدیریت وضوح و اطلاعات کدگذاری فایل‌های ویدئویی", + "transcoding_target_resolution": "وضوح هدف", + "transcoding_target_resolution_description": "وضوح‌های بالاتر می‌توانند جزئیات بیشتری را حفظ کنند، اما زمان بیشتری برای کدگذاری نیاز دارند، اندازه فایل‌های بزرگ‌تری دارند و ممکن است باعث کاهش پاسخگویی برنامه شوند.", + "transcoding_temporal_aq": "AQ موقتی", + "transcoding_temporal_aq_description": "این مورد فقط برای NVENC اعمال می شود. افزایش کیفیت در صحنه های با جزئیات بالا و حرکت کم. ممکن است با دستگاه های قدیمی تر سازگار نباشد.", + "transcoding_threads": "رشته ها ( موضوعات )", + "transcoding_threads_description": "مقادیر بالاتر منجر به رمزگذاری سریع تر می شود، اما فضای کمتری برای پردازش سایر وظایف سرور در حین فعالیت باقی می گذارد. این مقدار نباید بیشتر از تعداد هسته های CPU باشد. اگر روی 0 تنظیم شود، بیشترین استفاده را خواهد داشت.", "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_description": "تلاش برای حفظ ظاهر ویدیوهای HDR هنگام تبدیل به SDR. هر الگوریتم تعادل های متفاوتی را برای رنگ، جزئیات و روشنایی ایجاد می کند. Hable جزئیات را حفظ می کند، Mobius رنگ را حفظ می کند و Reinhard روشنایی را حفظ می کند.", "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_delete_immediately": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_restore_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" + "transcoding_tone_mapping_npl_description": "رنگ ها برای ظاهر طبیعی در یک نمایشگر با این روشنایی تنظیم خواهند شد. برخلاف انتظار، مقادیر پایین تر باعث افزایش روشنایی ویدیو و برعکس می شوند، زیرا آن را برای روشنایی نمایشگر جبران می کند. مقدار 0 این مقدار را به طور خودکار تنظیم می کند.", + "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 کدک کارآمدترین است، اما از پشتیبانی در دستگاه های قدیمی تر برخوردار نیست.", + "trash_enabled_description": "فعال سازی ویژگی های سطل بازیافت (سطل زباله)", + "trash_number_of_days": "تعداد روزها", + "trash_number_of_days_description": "تعداد روزهایی که دارایی ها(عکسها و فیملها) در زباله دان(سطل بازیافت) قبل از حذف دائمی نگهداری میشوند", + "trash_settings": "تنظیمات سطل بازیافت (سطل زباله)", + "trash_settings_description": "مدیریت تنظیمات سطل بازیافت (سطل زباله)", + "untracked_files": "فایل های ردیابی نشده", + "untracked_files_description": "این فایل ها توسط برنامه ردیابی نمی شوند. می توانند نتیجه انتقال ناموفق، بارگذاری متوقف شده یا به دلیل یک باگ باقی مانده باشند", + "user_delete_delay": "{user}'s حساب کاربری و دارایی ها(عکس و فیلم) برای حذف دائمی در {delay, plural, one {# روز} other {# روز}} برنامه ریزی خواهند شد.", + "user_delete_delay_settings": "تأخیر در حذف", + "user_delete_delay_settings_description": "تعداد روزهایی که پس از حذف، حساب کاربری و دارایی های(عکس و فیلم) کاربر به طور دائمی حذف می شوند. کار حذف کاربر در نیمه شب اجرا می شود تا کاربرانی که آماده حذف هستند را بررسی کند. تغییرات در این تنظیم در اجرای بعدی ارزیابی خواهند شد.", + "user_delete_immediately": "{user}'s حساب کاربری و دارایی ها (عکس و فیلم) فوراً برای حذف دائمی در صف قرار خواهند گرفت.", + "user_delete_immediately_checkbox": "کاربر و دارایی ها (عکس و فیلم) را برای حذف فوری در صف قرار بده", + "user_management": "مدیریت کاربر", + "user_password_has_been_reset": "رمز عبور کاربر بازنشانی شد:", + "user_password_reset_description": "لطفاً رمز عبور موقت را به کاربر ارائه دهید و به او اطلاع دهید که باید در ورود بعدی رمز عبور خود را تغییر دهد.", + "user_restore_description": "{user}'s حساب کاربری بازیابی خواهد شد.", + "user_restore_scheduled_removal": "بازیابی کاربر - حذف برنامه ریزی شده در {date, date, long}", + "user_settings": "تنظیمات کاربر", + "user_settings_description": "مدیریت تنظیمات کاربر", + "user_successfully_removed": "کاربر {email} با موفقیت حذف شد.", + "version_check_enabled_description": "فعال‌سازی بررسی نسخه", + "version_check_implications": "ویژگی بررسی نسخه به ارتباط دوره ای با github.com متکی است", + "version_check_settings": "بررسی نسخه", + "version_check_settings_description": "فعال یا غیرفعال کردن اعلان نسخه جدید", + "video_conversion_job": "تبدیل (رمزگذاری) ویدیوها", + "video_conversion_job_description": "تبدیل (رمزگذاری)ویدیوها برای سازگاری بیشتر با مرورگرها و دستگاه‌ها" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", + "admin_email": "ایمیل مدیر", + "admin_password": "رمز عبور مدیر", + "administration": "مدیریت", + "advanced": "پیشرفته", "album_added": "", "album_added_notification_setting_description": "", "album_cover_updated": "", @@ -517,8 +524,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -759,10 +766,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "", "repair": "", diff --git a/web/src/lib/i18n/fi.json b/i18n/fi.json similarity index 66% rename from web/src/lib/i18n/fi.json rename to i18n/fi.json index aa2e01952f..d3783691be 100644 --- a/web/src/lib/i18n/fi.json +++ b/i18n/fi.json @@ -17,7 +17,7 @@ "add_import_path": "Lisää tuontipolku", "add_location": "Lisää sijainti", "add_more_users": "Lisää käyttäjiä", - "add_partner": "Lisää kaveri", + "add_partner": "Lisää kumppani", "add_path": "Lisää polku", "add_photos": "Lisää kuvia", "add_to": "Lisää...", @@ -25,12 +25,14 @@ "add_to_shared_album": "Lisää jaettuun albumiin", "added_to_archive": "Arkistoitu", "added_to_favorites": "Lisätty suosikkeihin", - "added_to_favorites_count": "{count} lisätty suosikkeihin", + "added_to_favorites_count": "{count, number} lisätty suosikkeihin", "admin": { "add_exclusion_pattern_description": "Lisää mallit, jonka mukaan jätetään tiedostoja pois. Jokerimerkit *, ** ja ? ovat tuettuna. Jättääksesi pois kaikki tiedostot mistä tahansa löytyvästä kansiosta \"Raw\" käytä \"**/Raw/**\". Jättääksesi pois kaikki \". tif\" päätteiset tiedot, käytä \"**/*.tif\". Jättääksesi pois tarkan tiedostopolun, käytä \"/path/to/ignore/**\".", + "asset_offline_description": "Ulkoista kirjaston resurssia ei enää löydy levyltä, ja se on siirretty roskakoriin. Jos tiedosto siirrettiin kirjaston sisällä, tarkista aikajanaltasi uusi vastaava resurssi. Palautaaksesi tämän resurssin, varmista, että alla oleva tiedostopolku on Immichin käytettävissä ja skannaa kirjasto uudelleen.", "authentication_settings": "Autentikointiasetukset", "authentication_settings_description": "Hallitse salasana-, OAuth- ja muut autentikoinnin asetukset", "authentication_settings_disable_all": "Haluatko varmasti poistaa kaikki kirjautumistavat käytöstä? Kirjautuminen on tämän jälkeen mahdotonta.", + "authentication_settings_reenable": "Ottaaksesi uudestaan käyttöön, käytä Palvelin Komentoa.", "background_task_job": "Taustatyöt", "check_all": "Tarkista kaikki", "cleared_jobs": "Työn {job} tehtävät tyhjennetty", @@ -40,41 +42,52 @@ "confirm_email_below": "Kirjota \"{email}\" vahvistaaksesi", "confirm_reprocess_all_faces": "Haluatko varmasti käsitellä uudelleen kaikki kasvot? Tämä poistaa myös nimetyt henkilöt.", "confirm_user_password_reset": "Haluatko varmasti nollata käyttäjän {user} salasanan?", + "create_job": "Luo tehtävä", "crontab_guru": "Crontab Guru", "disable_login": "Poista kirjautuminen käytöstä", "disabled": "Ei käytössä", "duplicate_detection_job_description": "Tunnista samankaltaiset kuvat käyttäen koneoppimista. Tukeutuu Smart Search:iin", - "exclusion_pattern_description": "Poissulkevat määritteet mahdollistavat tiettyjen tiedostojen ja kansioiden jättämisen pois kirjastoasi skannatessa. Tästä on hyötyä jos kansiot sisältävät tiedostoja mitä et halua tuoda, kuten RAW-tiedostot.", + "exclusion_pattern_description": "Poissulkemismallit mahdollistavat tiettyjen tiedostojen ja kansioiden jättämisen pois kirjastoasi skannatessa. Tästä on hyötyä jos kansiot sisältävät tiedostoja mitä et halua tuoda, kuten RAW-tiedostot.", "external_library_created_at": "Ulkoinen kirjasto (luotu {date})", "external_library_management": "Ulkoisen kirjaston hallinta", - "face_detection": "Kasvojen haitseminen", - "face_detection_description": "Tunnista sisällön kasvoja käyttäen koneoppimista. Videojen osalta vain pikkukuva tunnistetaan. \"Kaikki\" (uudelleen)prosessoi koko sisällön. \"Puuttuvat\" prosessoi sisällön, jota ei vielä ole käyty läpi. Havaitut kasvot ryhmitellään jo tunnistettujen kanssa, tai lisätään uusina henkilöinä.", - "facial_recognition_job_description": "Ryhmitä havaitut kasvot henkilöihin. Tämä vaihe suoritetaan kun kasvot on ensin havaittu. \"Kaikki\" ryhmittelee kaikki kasvot. \"Puuttuvat\" vain ne, joille ei ole määritetty henkilöä.", + "face_detection": "Kasvojen havaitseminen", + "face_detection_description": "Tunnista sisällön kasvoja käyttäen koneoppimista. Videoiden osalta vain pikkukuva tunnistetaan. \"Päivitä\" (uudelleen)prosessoi koko sisällön.\"Nollaa\" lisäksi puhdistaa kaiken kasvo-datan. \"Puuttuvat\" prosessoi sisällön, jota ei vielä ole käyty läpi. Havaitut kasvot ryhmitellään jo tunnistettujen kanssa, tai lisätään uusina henkilöinä.", + "facial_recognition_job_description": "Ryhmitä havaitut kasvot henkilöihin. Tämä vaihe suoritetaan, kun kasvot on ensin havaittu. \"Nollaus\" (uudelleen-)ryhmittelee kaikki kasvot. \"Puuttuvat\" vain ne, joille ei ole määritetty henkilöä.", "failed_job_command": "Komento {command} epäonnistui työlle {job}", "force_delete_user_warning": "VAROITUS: Tämä poistaa käyttäjän ja kaikki mediat. Tätä ei voi perua, eikä tiedostoja voi palauttaa.", "forcing_refresh_library_files": "Pakotetaan virkistämään kaikkien kirjastojen tiedostot", + "image_format": "Tiedostomuoto", "image_format_description": "WebP tuottaa pienempiä tiedostoja kuin JPEG, mutta on hitaampi pakata.", "image_prefer_embedded_preview": "Suosi upotettua esikatselua", "image_prefer_embedded_preview_setting_description": "Käytä RAW-kuvissa upotettuja esikatselukuvia aina kun mahdollista. Tämä voi joissain kuvissa tuottaa tarkemmat värit, mutta esikatselun laatu on riippuvainen kamerasta ja kuvassa voi olla enemmän pakkauksesta aiheutuvia häiriöitä.", "image_prefer_wide_gamut": "Suosi laajaa väriskaalaa", "image_prefer_wide_gamut_setting_description": "Käytä Display P3 -nimiavaruutta pikkukuville. Tämä säilöö värien vivahteet paremmin, mutta kuvat saattavat näyttää erilaisilta vanhemmissa laitteissa. sRGB-kuvat pidetään muuttumattomina, jottei värit muuttuisi.", + "image_preview_description": "Keskikokoinen kuva, josta metatiedot on poistettu, käytetään yksittäisen resurssin katseluun ja koneoppimiseen", "image_preview_format": "Esikatselun muoto", + "image_preview_quality_description": "Esikatselulaatu 1-100. Korkeampi arvo on parempi, mutta tuottaa suurempia tiedostoja ja voi heikentää sovelluksen reagointikykyä. Matalan arvon asettaminen voi vaikuttaa koneoppimisen laatuun.", "image_preview_resolution": "Esikatselun resoluutio", "image_preview_resolution_description": "Käytetään kun katsellaan yksittäisiä kuvia, tai koneoppimiseen. Suurempi resoluutio voi säilyttää paremmin yksityiskohtia. Tosin koodaus kestää kauemmin, tiedostokoko kasvaa, ja se saattaa hidastaa sovelluksen responsiivisuutta.", + "image_preview_title": "Esikatselun asetukset", "image_quality": "Laatu", "image_quality_description": "Kuvan laatu välillä 1-100. Suurempi arvo on paremman laatuinen, mutta tuottaa kookkaampia tiedostoja. Tämä asetus vaikuttaa esikatselu- ja pikkukuviin.", + "image_resolution": "Resoluutio", + "image_resolution_description": "Korkeammat resoluutiot voivat säilyttää enemmän yksityiskohtia, mutta niiden koodaus kestää kauemmin, tiedostokoot ovat suurempia ja ne voivat heikentää sovelluksen reagointikykyä.", "image_settings": "Kuva-asetukset", - "image_settings_description": "Hallitse luotujen kuvien laatua ja resolutiota", + "image_settings_description": "Hallitse luotujen kuvien laatua ja resoluutiota", + "image_thumbnail_description": "Pieni pikkukuva, josta metatiedot on poistettu, käytetään valokuvaryhmien katseluun, kuten pääaikajanalla", "image_thumbnail_format": "Pikkukuvien muoto", + "image_thumbnail_quality_description": "Pikkukuvan laatu 1-100. Korkeampi arvo on parempi, mutta tuottaa suurempia tiedostoja ja voi heikentää sovelluksen reagointikykyä.", "image_thumbnail_resolution": "Pikkukuvien resoluutio", "image_thumbnail_resolution_description": "Käytetään katsottaessa useita kuvia kerralla (aikajana, albuminäkymä, jne.) Korkeampi resoluutio antaa enemmän yksityiskohtia, mutta niiden luonti kestää kauemmin, tiedostokoot ovat isompia ja voivat heikentää sovelluksen responsiivisuutta.", - "job_concurrency": "{job} yhtäaikaisuus", + "image_thumbnail_title": "Pikkukuva-asetukset", + "job_concurrency": "Tehtävän \"{job}\" samanaikaisuus", + "job_created": "Tehtävä luotu", "job_not_concurrency_safe": "Tätä tehtävää ei ole turvallista ajaa yhtäaikaisesti.", "job_settings": "Tehtävän asetukset", "job_settings_description": "Hallitse tehtävän samanaikaisuusasetuksia", "job_status": "Tehtävän tila", - "jobs_delayed": "{jobCount} tehtävää vivästetty", - "jobs_failed": "{jobCount} epäonnistui", + "jobs_delayed": "{jobCount, plural, other {# viivästynyttä}}", + "jobs_failed": "{jobCount, plural, other {# epäonnistunutta}}", "library_created": "Kirjasto {library} luotu", "library_cron_expression": "Cron-lauseke", "library_cron_expression_description": "Anna skannaustiheys cron-formaatissa. Saadaksesi lisätietoja katso esimerkiksi Crontab Guru", @@ -98,7 +111,7 @@ "machine_learning_duplicate_detection": "Kaksoiskappaleiden tunnistus", "machine_learning_duplicate_detection_enabled": "Ota käyttöön kaksoiskappaleiden tunnistus", "machine_learning_duplicate_detection_enabled_description": "Jos ei käytössä, täsmälleen samojen aineistojen kaksoiskappaleet tullaan silti poistamaan.", - "machine_learning_duplicate_detection_setting_description": "Etsi todennäköisiä kaksoiskappaleita CLIP upotuksien avulla", + "machine_learning_duplicate_detection_setting_description": "Etsi todennäköisiä kaksoiskappaleita CLIP-upotuksien avulla", "machine_learning_enabled": "Ota käyttöön koneoppiminen", "machine_learning_enabled_description": "Jos poistettu käytöstä, kaikki koneoppimistoiminnot ovat pois käytöstä riippumatta alla olevista asetuksista.", "machine_learning_facial_recognition": "Kasvojen tunnistus", @@ -118,7 +131,7 @@ "machine_learning_settings": "Koneoppimisen asetukset", "machine_learning_settings_description": "Koneoppimisen ominaisuudet ja asetukset", "machine_learning_smart_search": "Älykäs etsintä", - "machine_learning_smart_search_description": "Etsi kuvia merkityksellisemmin käyttäen CLIP upotuksia", + "machine_learning_smart_search_description": "Etsi kuvia merkityksellisemmin käyttäen CLIP-upotuksia", "machine_learning_smart_search_enabled": "Ota käyttöön älykäs haku", "machine_learning_smart_search_enabled_description": "Jos ei käytössä, kuvia ei koodata älykkäälle etsinnälle.", "machine_learning_url_description": "Koneoppimispalvelimen URL", @@ -126,16 +139,23 @@ "manage_log_settings": "Hallitse lokien asetuksia", "map_dark_style": "Tumma teema", "map_enable_description": "Ota käyttöön karttatoiminnot", + "map_gps_settings": "Kartta- ja GPS-asetukset", + "map_gps_settings_description": "Hallitse kartan ja GPS:n (käänteisen geokoodauksen) asetuksia", + "map_implications": "Karttaominaisuus käyttää ulkoista karttapalvelua (tiles.immich.cloud)", "map_light_style": "Vaalea teema", "map_manage_reverse_geocoding_settings": "Hallitse käänteisen geokoodauksen asetuksia", "map_reverse_geocoding": "Käänteinen Geokoodaus", "map_reverse_geocoding_enable_description": "Ota käyttöön osoitteiden poiminta karttakoordinaateista", - "map_reverse_geocoding_settings": "Käänteisen Geokoodauksen asetukset", - "map_settings": "Kartta- ja GPS asetukset", + "map_reverse_geocoding_settings": "Käänteisen geokoodauksen asetukset", + "map_settings": "Kartta", "map_settings_description": "Hallitse kartan asetuksia", - "map_style_description": "style.json -karttateeman URL", + "map_style_description": "style.json-karttateeman URL", "metadata_extraction_job": "Kerää metadata", - "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS, kasvot ja resoluutio", + "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF- ja kylkiäistiedostoista", + "metadata_settings": "Metatietoasetukset", + "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", "migration_job_description": "Migroi aineiston pikkukuvat ja kasvot uusimpaan kansiorakenteeseen", "no_paths_added": "Polkuja ei asetettu", @@ -144,10 +164,10 @@ "note_cannot_be_changed_later": "Huom: Tätä ei voi enää myöhemmin vaihtaa!", "note_unlimited_quota": "Huom: Määritä 0 rajoittamattomaksi kiintiöksi", "notification_email_from_address": "Lähettäjän osoite", - "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich Kuvapalvelin \"", + "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich-kuvapalvelin \"", "notification_email_host_description": "Sähköpostipalvelin (esim. smtp.immich.app)", - "notification_email_ignore_certificate_errors": "Älä huomioi sertifikaattivirheitä", - "notification_email_ignore_certificate_errors_description": "Älä huomioi TLS sertifikaattien validointivirheitä (ei suositeltu)", + "notification_email_ignore_certificate_errors": "Älä huomioi varmennevirheitä", + "notification_email_ignore_certificate_errors_description": "Älä huomioi TLS-varmenteiden validointivirheitä (ei suositeltu)", "notification_email_password_description": "Sähköpostipalvelimen salasana", "notification_email_port_description": "Sähköpostipalvelimen portti (esim. 25, 465, tai 587)", "notification_email_sent_test_email_button": "Lähetä testaussähköposti ja tallenna", @@ -160,20 +180,22 @@ "notification_settings": "Ilmoitusasetukset", "notification_settings_description": "Hallitse ilmoitusasetuksia, myös sähköpostin", "oauth_auto_launch": "Automaattinen käynnistys", - "oauth_auto_launch_description": "Aloita OAuth kirjautuminen heti kun saavutaan kirjautumissivulle", + "oauth_auto_launch_description": "Aloita OAuth-kirjautumisvuo heti kun saavutaan kirjautumissivulle", "oauth_auto_register": "Automaattinen rekisteröinti", "oauth_auto_register_description": "Rekisteröi uudet OAuth:lla kirjautuvat käyttäjät automaattisesti", "oauth_button_text": "Painikkeen teksti", "oauth_client_id": "Client ID", "oauth_client_secret": "Client Secret", - "oauth_enable_description": "Kirjaudu käyttäen OAuth:ia", + "oauth_enable_description": "Kirjaudu käyttäen OAuthia", "oauth_issuer_url": "Toimitsijan URL", "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", "oauth_mobile_redirect_uri_override": "Ohita mobiilin uudelleenohjaus-URI", - "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun 'app.immich:/' -ohjausta ei tueta.", + "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun OAuth tarjoaja ei salli mobiili URI:a, kuten '{callback}'", + "oauth_profile_signing_algorithm": "Profiilin allekirjoitusalgoritmi", + "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoittamiseen.", "oauth_scope": "Skooppi (Scope)", "oauth_settings": "OAuth", - "oauth_settings_description": "Hallitse OAuth kirjautumisen asetuksia", + "oauth_settings_description": "Hallitse OAuth-kirjautumisen asetuksia", "oauth_settings_more_details": "Saadaksesi lisätietoja tästä toiminnosta, katso dokumentaatio.", "oauth_signing_algorithm": "Allekirjoitusalgoritmi", "oauth_storage_label_claim": "Tallennustilan nimikkeen valtuutusväittämä (claim)", @@ -188,19 +210,22 @@ "password_settings": "Kirjaudu salasanalla", "password_settings_description": "Hallitse salasanakirjautumisen asetuksia", "paths_validated_successfully": "Kaikki polut validoitu", + "person_cleanup_job": "Henkilöpuhdistus", "quota_size_gib": "Kiintiön koko (Gt)", "refreshing_all_libraries": "Virkistetään kaikki kirjastot", "registration": "Pääkäyttäjän rekisteröinti", "registration_description": "Pääkäyttäjänä olet vastuussa järjestelmän hallinnallisista tehtävistä ja uusien käyttäjien luomisesta.", - "removing_offline_files": "Poistetaan Offline-tiedostot", + "removing_deleted_files": "Poistetaan Offline-tiedostot", "repair_all": "Korjaa kaikki", "repair_matched_items": "Löytyi {count, plural, one {# osuma} other {# osumaa}}", "repaired_items": "Korjattiin {count, plural, one {# kohta} other {# kohtaa}}", "require_password_change_on_login": "Vaadi käyttäjää vaihtamaan salasana ensimmäisellä kirjautumiskerralla", "reset_settings_to_default": "Nollaa asetukset oletuksille", "reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset", + "scanning_library": "Kirjastoa skannataan", "scanning_library_for_changed_files": "Etsitään kirjaston muuttuneita tiedostoja", "scanning_library_for_new_files": "Etsitään uusia tiedostoja", + "search_jobs": "Etsi tehtäviä...", "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", @@ -216,9 +241,9 @@ "storage_template_date_time_sample": "Esimerkki päivämäärä {date}", "storage_template_enable_description": "Ota käyttöön tallennustilan mallit", "storage_template_hash_verification_enabled": "Tarkistussumman varmennus käytössä", - "storage_template_hash_verification_enabled_description": "Ottaa käyttöön varmistussummien laskennan. Älä poista käytöstä jollet ole aivan varma seurauksista", + "storage_template_hash_verification_enabled_description": "Ottaa käyttöön tarkistussummien laskennan. Älä poista käytöstä, ellet ole aivan varma seurauksista", "storage_template_migration": "Tallennustilan mallien migraatio", - "storage_template_migration_description": "Käytä nykyistä {template}:a aikaisemmin lähetettyihin kohteisiin", + "storage_template_migration_description": "Käytä nykyistä {template}a aikaisemmin lähetettyihin kohteisiin", "storage_template_migration_info": "Malli vaikuttaa vain uusiin kohteisiin. Käyttääksesi mallia jo olemassa oleviin kohteisiin, aja {job}.", "storage_template_migration_job": "Tallennustilan mallin muutostyö", "storage_template_more_details": "Saadaksesi lisätietoa tästä ominaisuudesta, katso Tallennustilan Mallit sekä mihin se vaikuttaa", @@ -226,9 +251,11 @@ "storage_template_path_length": "Arvioitu tiedostopolun pituusrajoitus: {length, number}/{limit, number}", "storage_template_settings": "Tallennustilan malli", "storage_template_settings_description": "Hallitse palvelimelle ladatun aineiston kansiorakennetta ja tiedostonimiä", + "storage_template_user_label": "{label} on käyttäjän Tallennustilan Tunniste", "system_settings": "Järjestelmäasetukset", + "tag_cleanup_job": "Merkintäpuhdistus", "theme_custom_css_settings": "Mukautettu CSS", - "theme_custom_css_settings_description": "Kustomoi Immichin ulkoasua Cascading Style Sheets:llä.", + "theme_custom_css_settings_description": "Mukauta Immichin ulkoasua CSS:llä.", "theme_settings": "Teeman asetukset", "theme_settings_description": "Kustomoi Immichin web-käyttöliittymää", "these_files_matched_by_checksum": "Näillä tiedostoilla on yhteinen tarkistussumma", @@ -243,21 +270,24 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Sallitut äänikoodekit", "transcoding_accepted_audio_codecs_description": "Valitse mitä äänikoodekkeja ei tarvitse muuntaa. Käytetään vain tiettyjen koodauskäytäntöjen kanssa.", + "transcoding_accepted_containers": "Hyväksytyt kontit", + "transcoding_accepted_containers_description": "Valitse, mitä formaatteja ei tarvitse kääntää MP4- muotoon. Käytössä vain tietyille muunnos säännöille.", "transcoding_accepted_video_codecs": "Sallitut videokoodekit", "transcoding_accepted_video_codecs_description": "Valitse mitä videokoodekkeja ei tarvitse muuntaa. Käytetään vain tiettyjen koodauskäytäntöjen kanssa.", "transcoding_advanced_options_description": "Asetukset, joita useimpien käyttäjien ei tulisi muuttaa", "transcoding_audio_codec": "Äänikoodekki", "transcoding_audio_codec_description": "Opus on paras laadultaan, mutta ei välttämättä ole yhteensopiva vanhempien laitteiden tai sovellusten kanssa.", "transcoding_bitrate_description": "Videot, jotka ylittävät enimmäisbittinopeuden tai eivät ole hyväksytyssä muodossa", + "transcoding_codecs_learn_more": "Oppiaksesi lisää käytetystä terminologiasta, tutustu FFmpeg-dokumentaatioon: H.264-koodaaja, HEVC-koodaaja ja VP9-koodaaja.", "transcoding_constant_quality_mode": "Tasaisen laadun tyyppi", "transcoding_constant_quality_mode_description": "ICQ on parempi kuin CQP, mutta jotkut laitteistokiihdyttimet eivät tue sitä. Tätä asetusta käytetään oletuksena laatuun pohjautuvissa muunnoksissa, paitsi NVENC mikä ei tue ICQ:ta.", - "transcoding_constant_rate_factor": "", + "transcoding_constant_rate_factor": "Vakionopeustekijä", "transcoding_constant_rate_factor_description": "Videon laatu. Yleisimmät arvot ovat 23 H.264:lle, 28 HEVC:lle, 31 VP9:lle ja 35 AV1:lle. Matalampi arvo on parempi, mutta tekee isompia tiedostoja.", "transcoding_disabled_description": "Älä muunna videoita. Voi joissakin päätelaitteissa aiheuttaa videotoiston toimimattomuutta", "transcoding_hardware_acceleration": "Laitteistokiihdytys", "transcoding_hardware_acceleration_description": "Kokeellinen. Paljon nopeampi, mutta huonompaa laatua samalla bittinopeudella", "transcoding_hardware_decoding": "Laitteiston dekoodaus", - "transcoding_hardware_decoding_setting_description": "Vaikuttaa vain NVENC ja RKMPP -moottoreihin. Ottaa käyttöön end-to-end kiihdytyksen pelkän enkoodauksen sijasta. Ei välttämättä toimi kaikissa videoissa.", + "transcoding_hardware_decoding_setting_description": "Ottaa käyttöön end-to-end kiihdytyksen pelkän muuntamisen sijasta. Ei välttämättä toimi kaikissa videoissa.", "transcoding_hevc_codec": "HEVC koodekki", "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.", @@ -265,11 +295,11 @@ "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_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": "", + "transcoding_optimal_description": "Videot, joiden resoluutio on korkeampi kuin kohteen, tai ei hyväksytyssä formaatissa", "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", - "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin `faster`.", + "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin 'faster'.", "transcoding_reference_frames": "Kehysviitteet", "transcoding_reference_frames_description": "Viittaavien kehysten määrä kun tiettyä kehystä pakataan. Korkeampi arvo parantaa pakkausta mutta hidastaa enkoodausta. 0 määrittää arvon automaattisesti.", "transcoding_required_description": "Vain videoille, jotka eivät ole hyväksytyssä muodossa", @@ -288,25 +318,32 @@ "transcoding_transcode_policy": "Transkoodauskäytäntö", "transcoding_transcode_policy_description": "Käytäntö miten video tulisi transkoodata. HDR videot transkoodataan aina, paitsi jos transkoodaus on poistettu käytöstä.", "transcoding_two_pass_encoding": "Two-pass enkoodaus", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_two_pass_encoding_setting_description": "Transkoodaa kahdessa vaiheessa tuottaaksesi paremmin koodattuja videoita. Kun maksimibittinopeus on käytössä (vaaditaan H.264- ja HEVC-koodaukselle), tämä tila käyttää bittinopeusaluetta, joka perustuu maksimibittinopeuteen ja ohittaa CRF. VP9 osalta CRF:ää voidaan käyttää, jos maksimibittinopeus on poistettu käytöstä.", "transcoding_video_codec": "Videokoodekki", "transcoding_video_codec_description": "VP9 on tehokkain ja web-yhteensopiva, mutta muuntaminen kestää kauemmin. HEVC suoriutuu yhtäläisesti, mutta ei ole ihan yhtä yhteensopiva. H.264 on hyvin yhteensopiva ja nopea muuntaa, mutta tuottaa paljon suurempia tiedostoja. AV1 on kaikkein tehokkain koodekki, mutta vanhemmat laitteet eivät sitä tue.", "trash_enabled_description": "Ota käyttöön roskakori", "trash_number_of_days": "Päivien lukumäärä", - "trash_number_of_days_description": "Montako päivää aineistoja pidetään roskakorissa ennen pysyvää poistamista", + "trash_number_of_days_description": "Kuinka monta päivää aineistoja pidetään roskakorissa ennen pysyvää poistamista", "trash_settings": "Roskakorin asetukset", "trash_settings_description": "Hallitse roskakoriasetuksia", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_description": "Nämä tiedostot eivät ole ohjelman hallitsemia. Ne voivat olla virheellisten siirtojen tai keskeytyneiden latausten tulosta, tai bugista johtuvia jälkeen jääneitä", + "user_cleanup_job": "Käyttäjien puhdistus", + "user_delete_delay": "Käyttäjän {user} tili ja aineistot aikataulutetaan poistettavaksi ajan kuluttua: {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Poiston viive", - "user_delete_delay_settings_description": "Montako päivää poistamisen jälkeen käyttäjä ja hänen aineistonsa poistetaan pysyvästi. Joka keskiyö käydään läpi poistetuiksi merkityt käyttäjät. Tämä muutos astuu voimaan seuraavalla ajokerralla.", + "user_delete_delay_settings_description": "Kuinka monta päivää poistamisen jälkeen käyttäjä ja hänen aineistonsa poistetaan pysyvästi. Joka keskiyö käydään läpi poistettavaksi merkityt käyttäjät. Tämä muutos astuu voimaan seuraavalla ajokerralla.", + "user_delete_immediately": "{user}:n tili ja sen kohteet on ajastettu poistettavaksi heti.", + "user_delete_immediately_checkbox": "Aseta tili ja sen kohteet jonoon välitöntä poistoa varten", "user_management": "Käyttäjien hallinta", "user_password_has_been_reset": "Käyttäjän salasana on nollattu:", "user_password_reset_description": "Anna väliaikainen salasana ja ohjeista käyttäjää vaihtamaan se seuraavan kirjautumisen yhteydessä.", + "user_restore_description": "{user}:n tili palautetaan.", + "user_restore_scheduled_removal": "Palauta käyttäjä - Aikataulutettu poisto tapahtuu {date, date, long}", "user_settings": "Käyttäjäasetukset", "user_settings_description": "Hallitse käyttäjäasetuksia", "user_successfully_removed": "Käyttäjä {email} on poistettu.", - "version_check_enabled_description": "Ota käyttöön säännölliset uusien versioiden tarkistukset GitHubista", + "version_check_enabled_description": "Ota käyttöön versiotarkastus", + "version_check_implications": "Versiotarkistus vaatii säännöllisen yhteyden github.comiin", "version_check_settings": "Versiotarkistus", "version_check_settings_description": "Ota käyttöön ilmoitukset, kun uusi versio on saatavilla", "video_conversion_job": "Transkoodaa videot", @@ -322,14 +359,21 @@ "album_added": "Albumi lisätty", "album_added_notification_setting_description": "Saa sähköpostia kun sinut lisätään jaettuun albumiin", "album_cover_updated": "Albumin kansikuva päivitetty", - "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", + "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?", + "album_delete_confirmation_description": "Jos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", + "album_leave": "Poistu albumista?", + "album_leave_confirmation": "Haluatko varmasti poistua albumista {album}?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", + "album_remove_user": "Poista käyttäjä?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", + "album_user_left": "Poistuttiin albumista {album}", "album_user_removed": "{user} poistettu", + "album_with_link_access": "Anna kenen tahansa nähdä linkin kautta tämän albumin valokuvat ja henkilöt.", "albums": "Albumit", "albums_count": "{count, plural, one {{count, number} albumi} other {{count, number} albumia}}", "all": "Kaikki", @@ -338,7 +382,12 @@ "all_videos": "Kaikki videot", "allow_dark_mode": "Salli tumma tila", "allow_edits": "Salli muutokset", + "allow_public_user_to_download": "Salli julkisten käyttäjien ladata tiedostoja", + "allow_public_user_to_upload": "Salli julkisten käyttäjien lähettää tiedostoja", + "anti_clockwise": "Vastapäivään", "api_key": "API-avain", + "api_key_description": "Tämä arvo näytetään vain kerran. Varmista, että olet kopioinut sen ennen kuin suljet ikkunan.", + "api_key_empty": "API-avaimesi ei pitäisi olla tyhjä", "api_keys": "API-avaimet", "app_settings": "Sovellusasetukset", "appears_in": "Esiintyy albumeissa", @@ -352,35 +401,43 @@ "are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?", "asset_added_to_album": "Lisätty albumiin", "asset_adding_to_album": "Lisätään albumiin...", + "asset_description_updated": "Kohteen kuvaus on päivitetty", + "asset_filename_is_offline": "Kohde {filename} on offline-tilassa", + "asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja", + "asset_hashing": "Hajautetaan...", "asset_offline": "Aineisto offline-tilassa", + "asset_offline_description": "Tätä ulkoista resurssia ei enää löydy levyltä. Ole hyvä ja ota yhteyttä Immich-järjestelmänvalvojaan saadaksesi apua.", "asset_skipped": "Ohitettu", + "asset_skipped_in_trash": "Roskakorissa", "asset_uploaded": "Lähetetty", "asset_uploading": "Lähetetään…", "assets": "kohdetta", "assets_added_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}}", "assets_added_to_album_count": "Albumiin lisätty {count, plural, one {# kohde} other {# kohdetta}}", - "assets_added_to_name_count": "{name}:n lisätty {count, plural, one {# media} other {# mediaa}}", + "assets_added_to_name_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}} {hasName, select, true {{name}} other {uuteen albumiin}}", "assets_count": "{count, plural, one {# media} other {# mediaa}}", "assets_moved_to_trash": "Siirretty {count, plural, one {# aineisto} other {# aineistoa}} roskakoriin", "assets_moved_to_trash_count": "Siirretty {count, plural, one {# media} other {# mediaa}} roskakoriin", "assets_permanently_deleted_count": "{count, plural, one {# media} other {# mediaa}} poistettu pysyvästi", "assets_removed_count": "{count, plural, one {# media} other {# mediaa}} poistettu", - "assets_restore_confirmation": "Haluatko varmasti palauttaa kaikki roskakoriin siirretyt mediat? Et voi perua tätä toimintoa!", + "assets_restore_confirmation": "Haluatko varmasti palauttaa kaikki roskakoriisi siirretyt resurssit? Tätä toimintoa ei voi peruuttaa! Huomaa, että offline-resursseja ei voida palauttaa tällä tavalla.", "assets_restored_count": "{count, plural, one {# media} other {# mediaa}} palautettu", "assets_trashed_count": "{count, plural, one {# media} other {# mediaa}} siirretty roskakoriin", "assets_were_part_of_album_count": "{count, plural, one {Media oli} other {Mediat olivat}} jo albumissa", - "authorized_devices": "Auktorisoidut laitteet", + "authorized_devices": "Valtuutetut laitteet", "back": "Takaisin", "back_close_deselect": "Palaa, sulje tai poista valinnat", "backward": "Taaksepäin", "birthdate_saved": "Syntymäaika tallennettu", "birthdate_set_description": "Syntymäaikaa käytetään laskemaan henkilön ikä kuvanottohetkellä.", "blurred_background": "Sumennettu tausta", - "build": "Rakenna", - "build_image": "Rakenna kuva", + "bugs_and_feature_requests": "Bugit ja ominaisuuspyynnöt", + "build": "Koontiversio", + "build_image": "Koontiversion kuva", "bulk_delete_duplicates_confirmation": "Haluatko varmasti poistaa {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} kerralla? Tämä säilyttää kustakin mediasta kookkaimman ja poistaa loput pysyvästi. Et voi perua tätä!", "bulk_keep_duplicates_confirmation": "Haluatko varmasti säilyttää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}}? Tämä merkitsee kaikki kaksoiskappaleet ratkaistuiksi, eikä poista mitään.", "bulk_trash_duplicates_confirmation": "Haluatko varmasti siirtää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} roskakoriin? Tämä säilyttää kustakin mediasta kookkaimman ja siirtää loput roskakoriin.", + "buy": "Osta lisenssi Immich:iin", "camera": "Kamera", "camera_brand": "Kameran merkki", "camera_model": "Kameran malli", @@ -398,7 +455,7 @@ "change_location": "Vaihda sijainti", "change_name": "Vaihda nimi", "change_name_successfully": "Nimi vaihdettu", - "change_password": "Vaihda salasana", + "change_password": "Vaihda Salasana", "change_password_description": "Tämä on joko ensimmäinen kertasi kun kirjaudut järjestelmään, tai salasanasi on pyydetty vaihtamaan. Määritä uusi salasana alle.", "change_your_password": "Vaihda salasanasi", "changed_visibility_successfully": "Näkyvyys vaihdettu", @@ -408,11 +465,14 @@ "city": "Kaupunki", "clear": "Tyhjennä", "clear_all": "Tyhjennä kaikki", + "clear_all_recent_searches": "Tyhjennä viimeisimmät haut", "clear_message": "Tyhjennä viesti", "clear_value": "Tyhjää arvo", + "clockwise": "Myötäpäivään", "close": "Sulje", "collapse": "Supista", "collapse_all": "Sulje kaikki", + "color": "Väri", "color_theme": "Väriteema", "comment_deleted": "Kommentti poistettu", "comment_options": "Kommentin valinnat", @@ -446,13 +506,15 @@ "create_new_person": "Luo uusi henkilö", "create_new_person_hint": "Määritä valitut mediat uudelle henkilölle", "create_new_user": "Luo uusi käyttäjä", + "create_tag": "Luo tunniste", + "create_tag_description": "Luo uusi tunniste. Sisäkkäisiä tunnisteita varten syötä tunnisteen täydellinen polku kauttaviivat mukaan luettuna.", "create_user": "Luo käyttäjä", "created": "Luotu", "current_device": "Nykyinen laite", "custom_locale": "Muokatut maa-asetukset", "custom_locale_description": "Muotoile päivämäärät ja numerot perustuen alueen kieleen", "dark": "Tumma", - "date_after": "Päivä jälkeen", + "date_after": "Päivämäärän jälkeen", "date_and_time": "Päivämäärä ja aika", "date_before": "Päivä ennen", "date_of_birth_saved": "Syntymäaika tallennettu", @@ -469,13 +531,17 @@ "delete_library": "Poista kirjasto", "delete_link": "Poista linkki", "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ä", "deleted_shared_link": "Jaettu linkki poistettu", + "deletes_missing_assets": "Poistaa levyltä puuttuvat resurssit", "description": "Kuvaus", "details": "TIEDOT", "direction": "Suunta", "disabled": "Poistettu käytöstä", "disallow_edits": "Älä salli muokkauksia", + "discord": "Discord", "discover": "Tutki", "dismiss_all_errors": "Sivuuta kaikki virheet", "dismiss_error": "Sivuuta virhe", @@ -484,8 +550,11 @@ "display_original_photos": "Näytä alkuperäiset kuvat", "display_original_photos_setting_description": "Näytä mieluiten alkuperäinen kuva peukalokuvan sijasta kun alkuperäinen aineisto on web-yhteensopiva. Tämä voi aiheuttaa kuvien näyttämisen hitautta.", "do_not_show_again": "Älä näytä tätä enää", + "documentation": "Dokumentaatio", "done": "Valmis", "download": "Lataa", + "download_include_embedded_motion_videos": "Upotetut videot", + "download_include_embedded_motion_videos_description": "Sisällytä liikekuviin upotetut videot erillisinä tiedostoina", "download_settings": "Lataukset", "download_settings_description": "Hallitse aineiston lataukseen liittyviä asetuksia", "downloading": "Ladataan", @@ -515,10 +584,15 @@ "edit_location": "Muokkaa sijaintia", "edit_name": "Muokkaa nimeä", "edit_people": "Muokkaa henkilöitä", + "edit_tag": "Muokkaa tunnistetta", "edit_title": "Muokkaa otsikkoa", "edit_user": "Muokkaa käyttäjää", "edited": "Muokattu", - "editor": "", + "editor": "Editori", + "editor_close_without_save_prompt": "Muutoksia ei tallenneta", + "editor_close_without_save_title": "Suljetaanko editori?", + "editor_crop_tool_h2_aspect_ratios": "Kuvasuhteet", + "editor_crop_tool_h2_rotation": "Rotaatio", "email": "Sähköposti", "empty": "", "empty_album": "", @@ -546,6 +620,7 @@ "error_adding_users_to_album": "Käyttäjiä ei voitu lisätä albumiin", "error_deleting_shared_user": "Jaettua käyttäjää ei voitu poistaa", "error_downloading": "Tiedostoa {filename} ei voitu ladata", + "error_hiding_buy_button": "Virhe osta-painikkeen piilottamisessa", "error_removing_assets_from_album": "Medioiden poisto epäonnistui. Katso konsolista lisätietoja", "error_selecting_all_assets": "Kaikkia medioita ei voitu valita", "exclusion_pattern_already_exists": "Tämä poissulkemismalli on jo olemassa.", @@ -556,6 +631,8 @@ "failed_to_get_people": "Henkilöiden haku epäonnistui", "failed_to_load_asset": "Kohteen lataus epäonnistui", "failed_to_load_assets": "Kohteiden lataus epäonnistui", + "failed_to_load_people": "Henkilöiden lataus epäonnistui", + "failed_to_remove_product_key": "Tuoteavaimen poistaminen epäonnistui", "failed_to_stack_assets": "Medioiden pinoaminen epäonnistui", "failed_to_unstack_assets": "Medioiden pinoamisen purku epäonnistui", "import_path_already_exists": "Tämä tuontipolku on jo olemassa.", @@ -563,54 +640,91 @@ "paths_validation_failed": "{paths, plural, one {# polun} other {# polun}} validointi epäonnistui", "profile_picture_transparent_pixels": "Profiilikuvassa ei voi olla läpinäkyviä pikseleitä. Zoomaa lähemmäs ja/tai siirrä kuvaa.", "quota_higher_than_disk_size": "Asettamasi kiintiö on suurempi kuin levyn koko", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", + "repair_unable_to_check_items": "Ei voida tarkistaa {count, select, one {kohdetta} other {kohteita}}", + "unable_to_add_album_users": "Käyttäjiä ei voi lisätä albumiin", + "unable_to_add_assets_to_shared_link": "Medioiden lisääminen jaettuun linkkiin epäonnistui", + "unable_to_add_comment": "Kommentin lisääminen epäonnistui", + "unable_to_add_exclusion_pattern": "Ei voida lisätä poissulkemismallia", + "unable_to_add_import_path": "Tuontipolkua ei voitu lisätä", + "unable_to_add_partners": "Kumppaneita ei voitu lisätä", + "unable_to_add_remove_archive": "Ei voida {archived, select, true {poistaa kohdetta arkistosta} other {lisätä kohdetta arkistoon}}", + "unable_to_add_remove_favorites": "Ei voida {favorite, select, true {lisätä kohdetta suosikkeihin} other {poistaa kohdetta suosikeista}}", + "unable_to_archive_unarchive": "Ei voida {archived, select, true {arkistoida} other {poistaa arkistosta}}", + "unable_to_change_album_user_role": "Albumin käyttäjän roolia ei voitu muuttaa", + "unable_to_change_date": "Päivämäärää ei voitu muuttaa", + "unable_to_change_favorite": "Ei voida muuttaa suosikkia kohteelle", "unable_to_change_location": "Sijainnin muuttaminen epäonnistui", "unable_to_change_password": "Salasanan vaihto epäonnistui", + "unable_to_change_visibility": "Ei voida muuttaa näkyvyyttä {count, plural, one {# henkilölle} other {# henkilölle}}", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth-kirjautumista ei voitu suorittaa loppuun", + "unable_to_connect": "Yhteyttä ei voitu muodostaa", "unable_to_connect_to_server": "Palvelimeen ei saatu yhteyttä", + "unable_to_copy_to_clipboard": "Leikepöydälle ei voitu kopioida, varmista että käytät sivua https-yhteyden kautta", "unable_to_create_admin_account": "Pääkäyttäjän luominen epäonnistui", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_create_api_key": "Uuden API-avaimen luominen epäonnistui", + "unable_to_create_library": "Kirjaston luominen epäonnistui", + "unable_to_create_user": "Käyttäjän luominen epäonnistui", + "unable_to_delete_album": "Albumin poistaminen epäonnistui", + "unable_to_delete_asset": "Kohteen poistaminen epäonnistui", + "unable_to_delete_assets": "Virhe kohteen poistamisessa", + "unable_to_delete_exclusion_pattern": "Ei voida poistaa poissulkemismallia", + "unable_to_delete_import_path": "Tuontipolkua ei voitu poistaa", + "unable_to_delete_shared_link": "Jaetun linkin poistaminen epäonnistui", + "unable_to_delete_user": "Käyttäjän poistaminen epäonnistui", + "unable_to_download_files": "Tiedostojen lataaminen epäonnistui", + "unable_to_edit_exclusion_pattern": "Ei voida muokata poissulkemismallia", + "unable_to_edit_import_path": "Tuontipolkua ei voitu muokata", + "unable_to_empty_trash": "Roskakorin tyhjentäminen epäonnistui", + "unable_to_enter_fullscreen": "Koko ruudun tilaan siirtyminen epäonnistui", + "unable_to_exit_fullscreen": "Koko ruudun tilasta poistuminen epäonnistui", + "unable_to_get_comments_number": "Kommenttien määrän hakeminen epäonnistui", + "unable_to_get_shared_link": "Jaetun linkin hakeminen epäonnistui", + "unable_to_hide_person": "Henkilön piilottaminen epäonnistui", + "unable_to_link_motion_video": "Liikekuvan linkitys epäonnistui", + "unable_to_link_oauth_account": "OAuth-tilin linkittäminen epäonnistui", + "unable_to_load_album": "Albumin lataaminen epäonnistui", + "unable_to_load_asset_activity": "Ei voitu ladata kohteen toimintaa", + "unable_to_load_items": "Kohteiden lataaminen epäonnistui", + "unable_to_load_liked_status": "Ei voitu ladata tykkäyksen tilaa", + "unable_to_log_out_all_devices": "Kaikkien laitteiden uloskirjautuminen epäonnistui", + "unable_to_log_out_device": "Laitteen uloskirjautuminen epäonnistui", + "unable_to_login_with_oauth": "OAuth-kirjautuminen epäonnistui", + "unable_to_play_video": "Videon toistaminen epäonnistui", + "unable_to_reassign_assets_existing_person": "Ei voida siirtää kohteita {name, select, null {olemassa olevalle henkilölle} other {{name}}}", + "unable_to_reassign_assets_new_person": "Ei voida siirtää kohteita uudelle henkilölle", + "unable_to_refresh_user": "Käyttäjän päivittäminen epäonnistui", + "unable_to_remove_album_users": "Käyttäjien poistaminen albumista epäonnistui", + "unable_to_remove_api_key": "API-avaimen poistaminen epäonnistui", + "unable_to_remove_assets_from_shared_link": "kohteiden poistaminen jaetusta linkistä epäonnistui", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_deleted_assets": "Offline-tiedostoja ei voitu poistaa", + "unable_to_remove_library": "Kirjaston poistaminen epäonnistui", + "unable_to_remove_offline_files": "Offline-tiedostojen poistaminen epäonnistui", + "unable_to_remove_partner": "Kumppanin poistaminen epäonnistui", + "unable_to_remove_reaction": "Reaktion poistaminen epäonnistui", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", + "unable_to_repair_items": "Kohteiden korjaaminen epäonnistui", + "unable_to_reset_password": "Salasanan nollaaminen epäonnistui", + "unable_to_resolve_duplicate": "Virheilmoitus näkyy, kun palvelin palauttaa virheen painettaessa roskakorin tai säilytä-painiketta.", + "unable_to_restore_assets": "Kohteen palauttaminen epäonnistui", + "unable_to_restore_trash": "Kohteiden palauttaminen epäonnistui", + "unable_to_restore_user": "Käyttäjän palauttaminen epäonnistui", + "unable_to_save_album": "Albumin tallentaminen epäonnistui", + "unable_to_save_api_key": "API-avaimen tallentaminen epäonnistui", + "unable_to_save_date_of_birth": "Syntymäajan tallentaminen epäonnistui", + "unable_to_save_name": "Nimen tallentaminen epäonnistui", + "unable_to_save_profile": "Profiilin tallentaminen epäonnistui", + "unable_to_save_settings": "Asetusten tallentaminen epäonnistui", + "unable_to_scan_libraries": "Kirjastojen skannaaminen epäonnistui", + "unable_to_scan_library": "Kirjaston skannaaminen epäonnistui", + "unable_to_set_feature_photo": "Ei voida asettaa ominaiskuvaa", "unable_to_set_profile_picture": "Profiilikuvan asetus epäonnistui", "unable_to_submit_job": "Työtä ei voitu lähettää", "unable_to_trash_asset": "Median siirto roskakoriin epäonnistui", "unable_to_unlink_account": "Tunnuksen irroitus epäonnistui", + "unable_to_unlink_motion_video": "Ei voida irrottaa liikevideota", "unable_to_update_album_cover": "Albumin kannen päivitys epäonnistui", "unable_to_update_album_info": "Albumin tietojen päivitys epäonnistui", "unable_to_update_library": "Kirjaston päivitys epäonnistui", @@ -631,59 +745,82 @@ "expired": "Voimassaolo päättynyt", "expires_date": "Vanhenee {date}", "explore": "Tutki", + "explorer": "Selain", "export": "Vie", "export_as_json": "Vie JSON-muodossa", - "extension": "", - "external_libraries": "", + "extension": "Tiedostopääte", + "external": "Ulkoisesta", + "external_libraries": "Ulkoiset kirjastot", + "face_unassigned": "Ei määritelty", "failed_to_get_people": "", "favorite": "Suosikki", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "Suosikki- tai ei-suosikkikuva", "favorites": "Suosikit", "feature": "", "feature_photo_updated": "Kansikuva ladattu", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "features": "Ominaisuudet", + "features_setting_description": "Hallitse sovelluksen ominaisuuksia", + "file_name": "Tiedoston nimi", + "file_name_or_extension": "Tiedostonimi tai tiedostopääte", "filename": "Tiedostonimi", "files": "", "filetype": "Tiedostotyyppi", - "filter_people": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", + "filter_people": "Suodata henkilöt", + "find_them_fast": "Löydä nopeasti hakemalla nimellä", + "fix_incorrect_match": "Korjaa virheellinen osuma", + "folders": "Kansiot", + "folders_feature_description": "Käytetään kansionäkymää valokuvien ja videoiden selaamiseen järjestelmässä", + "force_re-scan_library_files": "Pakota kaikkien kirjastotiedostojen uudelleenskannaus", "forward": "Eteenpäin", - "general": "", - "get_help": "", - "getting_started": "", + "general": "Yleinen", + "get_help": "Hae apua", + "getting_started": "Aloittaminen", "go_back": "Palaa", - "go_to_search": "", + "go_to_search": "Siirry hakuun", "go_to_share_page": "", - "group_albums_by": "", + "group_albums_by": "Ryhmitä albumi...", "group_no": "Ei ryhmitystä", "group_owner": "Ryhmitä omistajan mukaan", "group_year": "Ryhmitä vuoden mukaan", - "has_quota": "", + "has_quota": "On kiintiö", "hi_user": "Hei {name} ({email})", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", + "hide_all_people": "Piilota kaikki henkilöt", + "hide_gallery": "Piilota galleria", + "hide_named_person": "Piilota henkilön {name}", + "hide_password": "Piilota salasana", + "hide_person": "Piilota henkilö", + "hide_unnamed_people": "Piilota nimeämättömät henkilöt", + "host": "Isäntä", "hour": "Tunti", "image": "Kuva", + "image_alt_text_date": "{isVideo, select, true {Video} other {Kuva}} otettu {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {person1} kanssa {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {additionalCount, number} muissa kanssa {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n kanssa {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {additionalCount, number} muun kanssa {date}", "img": "", - "immich_logo": "", - "import_path": "", + "immich_logo": "Immich-logo", + "immich_web_interface": "Immich-verkkokäyttöliittymä", + "import_from_json": "Tuo JSON-tiedostosta", + "import_path": "Tuontipolku", "in_albums": "{count, plural, one {# Albumissa} other {# albumissa}}", "in_archive": "Arkistossa", "include_archived": "Sisällytä arkistoidut", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", + "include_shared_albums": "Sisällytä jaetut albumit", + "include_shared_partner_assets": "Sisällytä jaetut kumppanikohteet", + "individual_share": "Yksittäinen jako", "info": "Lisätietoja", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Joka päivä klo 13:00", + "hours": "Joka {hours, plural, one {tunti} other {{hours, number} tuntia}}", + "night_at_midnight": "Joka yö keskiyöllä", + "night_at_twoam": "Joka yö klo 02:00" }, "invite_people": "Kutsu ihmisiä", "invite_to_album": "Kutsu albumiin", @@ -697,47 +834,59 @@ "language_setting_description": "Valitse suosimasi kieli", "last_seen": "Viimeksi nähty", "latest_version": "Viimeisin versio", + "latitude": "Leveysaste", "leave": "Lähde", "let_others_respond": "Anna muiden vastata", "level": "Taso", "library": "Kirjasto", - "library_options": "", + "library_options": "Kirjastovaihtoehdot", "license_button_buy": "Osta", "license_button_select": "Valitse", "light": "Vaalea", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", + "like_deleted": "Tykkäys poistettu", + "link_motion_video": "Linkitä liikevideo", + "link_options": "Linkin asetukset", + "link_to_oauth": "Linkki OAuth", + "linked_oauth_account": "Linkitetty OAuth-tili", "list": "Lista", "loading": "Ladataan", - "loading_search_results_failed": "", + "loading_search_results_failed": "Hakutulosten lataaminen epäonnistui", "log_out": "Kirjaudu ulos", "log_out_all_devices": "Kirjaudu ulos kaikilta laitteilta", + "logged_out_all_devices": "Kaikki laitteet kirjattu ulos", + "logged_out_device": "Laite kirjattu ulos", "login": "Kirjaudu", "login_has_been_disabled": "Kirjautuminen on otettu pois käytöstä.", "logout_all_device_confirmation": "Haluatko varmasti kirjautua ulos kaikilta laitteilta?", "logout_this_device_confirmation": "Haluatko varmasti kirjautua ulos näiltä laitteilta?", + "longitude": "Pituusaste", "look": "Tyyli", - "loop_videos": "", - "loop_videos_description": "", + "loop_videos": "Toista videot uudelleen", + "loop_videos_description": "Ota käyttöön videon automaattinen toisto tarkemmassa näkymässä.", + "main_branch_warning": "Käytät kehitysversiota; suosittelemme vahvasti käyttämään julkaisuversiota!", "make": "Valmistaja", "manage_shared_links": "Hallitse jaettuja linkkejä", - "manage_sharing_with_partners": "", + "manage_sharing_with_partners": "Hallitse jakamista kumppaneille", "manage_the_app_settings": "Hallitse sovelluksen asetuksia", "manage_your_account": "Hallitse tiliäsi", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", + "manage_your_api_keys": "Hallitse API-avaimiasi", + "manage_your_devices": "Hallitse sisäänkirjautuneita laitteitasi", + "manage_your_oauth_connection": "Hallitse OAuth-yhteyttäsi", "map": "Kartta", - "map_marker_with_image": "", + "map_marker_for_images": "Karttamarkerointi kuville, jotka on otettu kaupungissa {city}, maassa {country}", + "map_marker_with_image": "Karttamarkerointi kuvalla", "map_settings": "Kartta-asetukset", + "matches": "Osumia", "media_type": "Median tyyppi", - "memories": "", - "memories_setting_description": "", + "memories": "Muistoja", + "memories_setting_description": "Hallitse mitä näet muistoissasi", "memory": "Muisto", + "memory_lane_title": "Muistojen polku {title}", "menu": "Valikko", "merge": "Yhdistä", "merge_people": "Yhdistä henkilöt", + "merge_people_limit": "Voit yhdistää vain enintään 5 kasvoa kerrallaan", + "merge_people_prompt": "Haluatko yhdistää nämä henkilöt? Tätä valintaa ei voi peruuttaa.", "merge_people_successfully": "Henkilöt yhdistetty", "merged_people_count": "{count, plural, one {# Henkilö} other {# henkilöä}} yhdistetty", "minimize": "PIenennä", @@ -751,7 +900,8 @@ "name": "Nimi", "name_or_nickname": "Nimi tai lempinimi", "never": "ei koskaan", - "new_api_key": "Uusi API Key", + "new_album": "Uusi Albumi", + "new_api_key": "Uusi API-avain", "new_password": "Uusi salasana", "new_person": "Uusi henkilö", "new_user_created": "Uusi käyttäjä lisätty", @@ -763,42 +913,56 @@ "no_albums_message": "Luo albumi pitääksesi kuvat ja videot järjestyksessä", "no_albums_with_name_yet": "Näyttää siltä, ettei sinulla ole yhtään tämän nimistä albumia.", "no_albums_yet": "Näyttää siltä, ettei sinulla ole vielä yhtään albumia.", - "no_archived_assets_message": "", + "no_archived_assets_message": "Arkistoi kuvia ja videoita piilottaaksesi ne kuvat näkymästä", "no_assets_message": "NAPAUTA LATAAKSESI ENSIMMÄISEN KUVASI", + "no_duplicates_found": "Kaksoiskappaleita ei löytynyt.", "no_exif_info_available": "EXIF-tietoa ei saatavilla", - "no_explore_results_message": "", + "no_explore_results_message": "Lataa lisää kuvia tutkiaksesi kokoelmaasi.", "no_favorites_message": "Lisää suosikkeja löytääksesi nopeasti parhaat kuvasi ja videosi", - "no_libraries_message": "", + "no_libraries_message": "Luo ulkoinen kirjasto nähdäksesi valokuvasi ja videot", "no_name": "Ei nimeä", - "no_places": "", + "no_places": "Ei paikkoja", "no_results": "Ei tuloksia", + "no_results_description": "Kokeile synonyymiä tai yleisempää avainsanaa", "no_shared_albums_message": "Luo albumi, jotta voit jakaa kuvia ja videoita toisille", "not_in_any_album": "Ei yhdessäkään albumissa", + "note_apply_storage_label_to_previously_uploaded assets": "Huom: Jotta voit soveltaa tallennustunnistetta aiemmin ladattuihin kohteisiin, suorita", + "note_unlimited_quota": "Huomio: Syötä 0 rajoittamatonta kiintiötä varten", "notes": "Muistiinpanot", - "notification_toggle_setting_description": "Ota sähköpostilmoitukset käyttöön", + "notification_toggle_setting_description": "Ota sähköposti-ilmoitukset käyttöön", "notifications": "Ilmoitukset", "notifications_setting_description": "Hallitse ilmoituksia", "oauth": "OAuth", - "offline": "", + "official_immich_resources": "Viralliset Immich-resurssit", + "offline": "Offline", + "offline_paths": "Offline-polut", + "offline_paths_description": "Nämä tulokset voivat johtua tiedostojen manuaalisesta poistamisesta, jotka eivät ole osa ulkoista kirjastoa.", "ok": "Ok", "oldest_first": "Vanhin ensin", + "onboarding": "Käyttöönotto", + "onboarding_privacy_description": "Seuraavat (valinnaiset) ominaisuudet perustuvat ulkoisiin palveluihin, ja ne voidaan poistaa käytöstä milloin tahansa hallinta asetuksista.", + "onboarding_theme_description": "Valitse väriteema istunnollesi. Voit muuttaa tämän myöhemmin asetuksistasi.", + "onboarding_welcome_description": "Aloitetaa laittamalla istuntoosi joitakin yleisiä asetuksia.", "onboarding_welcome_user": "Tervetuloa {user}", "online": "Online", "only_favorites": "Vain suosikit", - "only_refreshes_modified_files": "", + "only_refreshes_modified_files": "Päivittää vain muakatut tiedostot", + "open_in_map_view": "Avaa karttanäkymässä", "open_in_openstreetmap": "Avaa OpenStreetMapissa", - "open_the_search_filters": "", + "open_the_search_filters": "Avaa hakusuodattimet", "options": "Vaihtoehdot", "or": "tai", "organize_your_library": "Järjestele kirjastosi", "original": "alkuperäinen", "other": "Muut", "other_devices": "Toiset laitteet", - "other_variables": "", + "other_variables": "Muut muuttujat", "owned": "Omistettu", "owner": "Omistaja", "partner": "Kumppani", "partner_can_access": "{partner} voi päästä", + "partner_can_access_assets": "Kaikki valokuvasi ja videosi, lukuun ottamatta arkistoituja ja poistettuja", + "partner_can_access_location": "Sijainti, jossa kuvasi on otettu", "partner_sharing": "Kumppanijako", "partners": "Kumppanit", "password": "Salasana", @@ -806,22 +970,26 @@ "password_required": "Salasana vaaditaan", "password_reset_success": "Salasanan nollaus onnistui", "past_durations": { - "days": "{years, plural, one {Viimeisin päivä} other {Viimeiset # päivää}}", - "hours": "{years, plural, one {Viimeisin tunti} other {Viimeiset # tuntia}}", + "days": "Viime {days, plural, one {päivä} other {# päivää}}", + "hours": "Viime {hours, plural, one {tunti} other {# tuntia}}", "years": "{years, plural, one {Viimeisin vuosi} other {Viimeiset # vuotta}}" }, "path": "Polku", - "pattern": "", + "pattern": "Kaava", "pause": "Tauko", - "pause_memories": "", + "pause_memories": "Pysäytä muistot", "paused": "Tauotettu", "pending": "Odottaa", "people": "Ihmiset", - "people_sidebar_description": "", + "people_edits_count": "Muokattu {count, plural, one {# henkilö} other {# henkilöä}}", + "people_feature_description": "Selataan valokuvia ja videoita, jotka on ryhmitelty henkilöiden mukaan", + "people_sidebar_description": "Näytä linkki Henkilöihin sivupalkissa", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Pysyvän poiston varoitus", + "permanent_deletion_warning_setting_description": "Näytä varoitus, kun poistat kohteita pysyvästi", "permanently_delete": "Poista pysyvästi", + "permanently_delete_assets_count": "Poista pysyvästi {count, plural, one {kohde} other {kohteita}}", + "permanently_delete_assets_prompt": "Oletko varma, että haluat poistaa pysyvästi {count, plural, one {tämän kohteen?} other {nämä # kohteet?}} Tämä poistaa myös {count, plural, one {sen sen} other {ne niiden}} albumista.", "permanently_deleted_asset": "Media poistettu pysyvästi", "permanently_deleted_assets_count": "{count, plural, one {# media} other {# mediaa}} poistettu pysyvästi", "person": "Henkilö", @@ -836,7 +1004,7 @@ "places": "Paikat", "play": "Toista", "play_memories": "Toista muistot", - "play_motion_photo": "", + "play_motion_photo": "Toista Liikekuva", "play_or_pause_video": "Toista tai keskeytä video", "point": "", "port": "Portti", @@ -846,26 +1014,66 @@ "previous_memory": "Edellinen muisto", "previous_or_next_photo": "Edellinen tai seuraava kuva", "primary": "Ensisijainen", + "privacy": "Yksityisyys", "profile_image_of_user": "Käyttäjän {user} profiilikuva", "profile_picture_set": "Profiilikuva asetettu.", "public_album": "Julkinen albumi", "public_share": "Julkinen jako", + "purchase_account_info": "Tukija", + "purchase_activated_subtitle": "Kiitos Immichin ja avoimen lähdekoodin ohjelmiston tukemisesta", + "purchase_activated_time": "Aktivoitu {date, date}", + "purchase_activated_title": "Avaimesi on aktivoitu onnistuneesti", + "purchase_button_activate": "Aktivoi", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Älä näytä koskaan uudelleen", + "purchase_button_reminder": "Muistuta minua 30 päivän kuluessa", + "purchase_button_remove_key": "Poista avain", + "purchase_button_select": "Valitse", + "purchase_failed_activation": "Aktivointi epäonnistui! Tarkista sähköpostisi oikean tuoteavaimen varalta!", + "purchase_individual_description_1": "Yksittäiselle henkilölle", + "purchase_individual_description_2": "Tukijan tila", + "purchase_individual_title": "Yksittäinen", + "purchase_input_suggestion": "Onko sinulla tuoteavain? Syötä avain alle", + "purchase_license_subtitle": "Osta Immich tukeaksesi palvelun jatkuvaa kehittämistä", + "purchase_lifetime_description": "Elinikäinen osto", + "purchase_option_title": "OSTOVAIHTOEHDOT", + "purchase_panel_info_1": "Immichin rakentaminen vie paljon aikaa ja vaivannäköä, ja meillä on kokopäiväisiä insinöörejä työskentelemässä sen parissa, jotta voimme tehdä siitä mahdollisimman hyvän. Missiomme on, että avoimen lähdekoodin ohjelmistosta ja eettisistä liiketoimintakäytännöistä tulee kestävä tulonlähde kehittäjille, sekä luoda yksityisyyttä kunnioittava ekosysteemi, jossa on todellisia vaihtoehtoja hyväksikäyttöön perustuville pilvipalveluille.", + "purchase_panel_info_2": "Koska olemme sitoutuneet siihen, ettemme lisää maksumuuria, tämä osto ei anna sinulle mitään lisäominaisuuksia Immichissa. Luotamme kaltaisiisi käyttäjiin tukeaksemme Immichin jatkuvaa kehittämistä.", + "purchase_panel_title": "Tue projektia", + "purchase_per_server": "Per palvelin", + "purchase_per_user": "Per käyttäjä", + "purchase_remove_product_key": "Poista Tuoteavain", + "purchase_remove_product_key_prompt": "Haluatko varmasti poistaa tuoteavaimen?", + "purchase_remove_server_product_key": "Poista palvelimen tuoteavain", + "purchase_remove_server_product_key_prompt": "Haluatko varmasti poistaa palvelimen tuoteavaimen?", + "purchase_server_description_1": "Koko palvelimelle", + "purchase_server_description_2": "Tukijan tila", + "purchase_server_title": "Palvelin", + "purchase_settings_server_activated": "Palvelimen tuoteavainta hallinnoi ylläpitäjä", "range": "", + "rating": "Tähtiarvostelu", + "rating_clear": "Tyhjennä arvostelu", + "rating_count": "{count, plural, one {# tähti} other {# tähteä}}", + "rating_description": "Näytä EXIF-arvosana lisätietopaneelissa", "raw": "", - "reaction_options": "", + "reaction_options": "Reaktioasetukset", "read_changelog": "Lue muutosloki", "reassign": "Määritä uudelleen", + "reassigned_assets_to_existing_person": "Uudelleen määritetty {count, plural, one {# kohde} other {# kohdetta}} {name, select, null {olemassa olevalle henkilölle} other {{name}}}", "reassigned_assets_to_new_person": "Määritetty {count, plural, one {# media} other {# mediaa}} uudelle henkilölle", "reassing_hint": "Määritä valitut mediat käyttäjälle", "recent": "Viimeisin", "recent_searches": "Edelliset haut", "refresh": "Päivitä", "refresh_encoded_videos": "Päivitä enkoodatut videot", + "refresh_faces": "Päivitä kasvot", "refresh_metadata": "Päivitä metadata", "refresh_thumbnails": "Päivitä pikkukuvat", "refreshed": "Päivitetty", - "refreshes_every_file": "Päivittää jokaisen tiedoston", + "refreshes_every_file": "Lukee uudelleen kaikki olemassa olevat ja uudet tiedostot", "refreshing_encoded_video": "Päivitetään enkoodattu video", + "refreshing_faces": "Päivitetään kasvoja", "refreshing_metadata": "Päivitetään metadata", "regenerating_thumbnails": "Regeneroidaan pikkukuvia", "remove": "Poista", @@ -873,18 +1081,19 @@ "remove_assets_shared_link_confirmation": "Haluatko varmasti poistaa {count, plural, one {# median} other {# mediaa}} tästä jakolinkistä?", "remove_assets_title": "Poistetaanko?", "remove_custom_date_range": "Poista aikaväliltä", + "remove_deleted_assets": "Poista Offline-tiedostot", "remove_from_album": "Poista albumista", "remove_from_favorites": "Poista suosikeista", "remove_from_shared_link": "Poista jakolinkistä", - "remove_offline_files": "Poista Offline-tiedostot", "remove_user": "Poista käyttäjä", - "removed_api_key": "API Key {name} poistettu", + "removed_api_key": "API-avain {name} poistettu", "removed_from_archive": "Poistettu arkistosta", "removed_from_favorites": "Poistettu suosikeista", "removed_from_favorites_count": "{count, plural, other {Poistettu #}} suosikeista", + "removed_tagged_assets": "Poistettu tunniste {count, plural, one {# kohteesta} other {# kohteesta}}", "rename": "Nimeä uudelleen", "repair": "Korjaa", - "repair_no_results_message": "", + "repair_no_results_message": "Seuraamattomat ja puuttuvat tiedostot näkyvät täällä", "replace_with_upload": "Korvaa tiedostolla", "repository": "Tietovarasto", "require_password": "Vaadi salasana", @@ -894,6 +1103,7 @@ "reset_people_visibility": "Nollaa henkilöiden näkyvyysasetukset", "reset_settings_to_default": "", "reset_to_default": "Palauta oletusasetukset", + "resolve_duplicates": "Ratkaise kaksoiskappaleet", "resolved_all_duplicates": "Kaikki kaksoiskappaleet selvitetty", "restore": "Palauta", "restore_all": "Palauta kaikki", @@ -903,21 +1113,24 @@ "retry_upload": "Yritä latausta uudelleen", "review_duplicates": "Tarkastele kaksoiskappaleita", "role": "Rooli", - "role_editor": "Muokkain", + "role_editor": "Editori", "role_viewer": "Toistin", "save": "Tallenna", - "saved_api_key": "API Key tallennettu", + "saved_api_key": "API-avain tallennettu", "saved_profile": "Profiili tallennettu", "saved_settings": "Asetukset tallennettu", "say_something": "Sano jotain", "scan_all_libraries": "Skannaa kaikki kirjastot", "scan_all_library_files": "Skannaa uudelleen kaikki kirjastotiedostot", + "scan_library": "Skannaa", "scan_new_library_files": "Skannaa uusia kirjastotiedostoja", "scan_settings": "Skannausasetukset", "scanning_for_album": "Etsitään albumia...", "search": "Haku", "search_albums": "Etsi albumeita", "search_by_context": "Etsi kontekstin 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ä...", "search_camera_model": "Etsi kameramallia...", "search_city": "Etsi kaupunkia...", @@ -925,9 +1138,12 @@ "search_for_existing_person": "Etsi olemassa olevaa henkilöä", "search_no_people": "Ei henkilöitä", "search_no_people_named": "Ei \"{name}\" nimisiä henkilöitä", + "search_options": "Hakuvaihtoehdot", "search_people": "Etsi ihmisiä", "search_places": "Etsi paikkoja", + "search_settings": "Hakuasetukset", "search_state": "Etsi tilaa...", + "search_tags": "Etsi tunnisteita...", "search_timezone": "Etsi aikavyöhyke...", "search_type": "Etsinnän tyyppi", "search_your_photos": "Etsi kuvia", @@ -936,6 +1152,7 @@ "see_all_people": "Näytä kaikki henkilöt", "select_album_cover": "Valitse albmin kansi", "select_all": "Valitse kaikki", + "select_all_duplicates": "Valitse kaikki kaksoiskappaleet", "select_avatar_color": "Valitse avatarin väri", "select_face": "Valitse kasvo", "select_featured_photo": "Valitse esittelykuva", @@ -950,6 +1167,8 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_offline": "Palvelin Offline-tilassa", + "server_online": "Palvelin Online-tilassa", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", "set": "Aseta", @@ -965,15 +1184,17 @@ "shared_by": "Jakanut", "shared_by_user": "Käyttäjän {user} jakama", "shared_by_you": "Sinun jakamasi", - "shared_from_partner": "{partner}n kuvia", + "shared_from_partner": "Kumppanin {partner} kuvia", + "shared_link_options": "Jaetun linkin vaihtoehdot", "shared_links": "Jaetut linkit", "shared_photos_and_videos_count": "{assetCount, plural, other {# jaettua kuvaa ja videota.}}", - "shared_with_partner": "Jaa {partner} kanssa", + "shared_with_partner": "Jaa kumppanin {partner} kanssa", "sharing": "Jakaminen", "sharing_enter_password": "Nähdäksesi sivun sinun tulee antaa salasana.", "sharing_sidebar_description": "Näytä jakamislinkki sivupalkissa", "shift_to_permanent_delete": "Paina ⇧ poistaaksesi median pysyvästi", "show_album_options": "Näytä albumin asetukset", + "show_albums": "Näytä albumit", "show_all_people": "Näytä kaikki henkilöt", "show_and_hide_people": "Näytä / piilota henkilöitä", "show_file_location": "Näytä tiedostosijainti", @@ -988,11 +1209,18 @@ "show_person_options": "Näytä henkilöasetukset", "show_progress_bar": "Näytä eteneminen", "show_search_options": "Näytä hakuvaihtoehdot", + "show_slideshow_transition": "Näytä diaesitys siirtymä", + "show_supporter_badge": "Kannattajan merkki", + "show_supporter_badge_description": "Näytä kannattajan merkki", "shuffle": "Sekoita", + "sidebar": "Sivupalkki", + "sidebar_display_description": "Näytä linkki näkymään sivupalkissa", "sign_out": "Kirjaudu ulos", "sign_up": "Rekisteröidy", "size": "Koko", "skip_to_content": "Siirry sisältöön", + "skip_to_folders": "Siirry kansioihin", + "skip_to_tags": "Siirry tunnisteisiin", "slideshow": "Diaesitys", "slideshow_settings": "Diaesityksen asetukset", "sort_albums_by": "Järjestä albumit...", @@ -1002,13 +1230,15 @@ "sort_oldest": "Vanhin kuva", "sort_recent": "Tuorein kuva", "sort_title": "Otsikko", - "source": "Lähde", + "source": "Lähdekoodi", "stack": "Pinoa", + "stack_duplicates": "Pinoa kaksoiskappaleet", + "stack_select_one_photo": "Valitse yksi pääkuva pinolle", "stack_selected_photos": "Pinoa valitut kuvat", "stacked_assets_count": "Pinottu {count, plural, one {# media} other {# mediaa}}", "stacktrace": "Vianetsintätiedot", "start": "Aloita", - "start_date": "Alkupäivämäärä", + "start_date": "Alkupäivä", "state": "Maakunta/osavaltio", "status": "Tila", "stop_motion_photo": "Pysäytä liikkuva kuva", @@ -1021,27 +1251,40 @@ "submit": "Lähetä", "suggestions": "Ehdotukset", "sunrise_on_the_beach": "Auringonnousu rannalla", + "support": "Tuki", + "support_and_feedback": "Tuki ja palaute", + "support_third_party_description": "Immich-asennuksesi on pakattu kolmannen osapuolen toimesta. Kohtaamasi ongelmat saattavat johtua tästä paketista, joten ilmoita niistä ensisijaisesti heille alla olevien linkkien kautta.", "swap_merge_direction": "Käännä yhdistämissuunta", "sync": "Synkronoi", + "tag": "Lisää tunniste", + "tag_assets": "Lisää tunnisteita", + "tag_created": "Luotu tunniste: {tag}", + "tag_feature_description": "Selaa valokuvia ja videoita, jotka on ryhmitelty loogisten tunnisteotsikoiden mukaan", + "tag_not_found_question": "Etkö löydä tunnistetta? Luo uusi tunniste ", + "tag_updated": "Päivitetty tunniste: {tag}", + "tagged_assets": "Tunnistettu {count, plural, one {# kohde} other {# kohdetta}}", + "tags": "Tunnisteet", "template": "Template", "theme": "Teema", "theme_selection": "Teeman valinta", "theme_selection_description": "Aseta vaalea tai tumma tila automaattisesti perustuen selaimesi asetuksiin", "they_will_be_merged_together": "Nämä tullaan yhdistämään", + "third_party_resources": "Kolmannen osapuolen resurssit", "time_based_memories": "Aikaan perustuvat muistot", "timezone": "Aikavyöhyke", "to_archive": "Arkistoi", "to_change_password": "Vaihda salasana", "to_favorite": "Aseta suosikiksi", "to_login": "Kirjaudu sisään", + "to_parent": "Siirry vanhempaan", "to_trash": "Roskakoriin", "toggle_settings": "Määritä asetukset", - "toggle_theme": "Aseta teema", + "toggle_theme": "Aseta tumma teema", "toggle_visibility": "Aseta näkyvyys", "total_usage": "Käyttö yhteensä", "trash": "Roskakori", "trash_all": "Vie kaikki roskakoriin", - "trash_count": "Vie {count} roskakoriin", + "trash_count": "Roskakori {count, number}", "trash_delete_asset": "Poista / vie roskakoriin", "trash_no_results_message": "Roskakorissa olevat kuvat ja videot näytetään täällä.", "trashed_items_will_be_permanently_deleted_after": "Roskakorin kohteet poistetaan pysyvästi {days, plural, one {# päivän} other {# päivän}} päästä.", @@ -1055,13 +1298,17 @@ "unknown_album": "", "unknown_year": "Tuntematon vuosi", "unlimited": "Rajoittamaton", + "unlink_motion_video": "Poista liikevideon linkitys", "unlink_oauth": "Poista OAuth-linkitys", "unlinked_oauth_account": "Linkittämätön OAuth-tili", "unnamed_album": "Nimetön albumi", + "unnamed_album_delete_confirmation": "Haluatko varmasti poistaa tämän albumin?", "unnamed_share": "Nimetön jako", "unsaved_change": "Tallentamaton muutos", "unselect_all": "Poista valinnat", + "unselect_all_duplicates": "Poista kaikkien kaksoiskappaleiden valinta", "unstack": "Pura pino", + "unstacked_assets_count": "Poistettu pinosta {count, plural, one {# kohde} other {# kohdetta}}", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_decription": "Järjestelmä ei seuraa näitä tiedostoja. Ne voivat johtua epäonnistuneista siirroista, keskeytyneistä latauksista, tai ovat jääneet ohjelmavian seurauksena", "up_next": "Seuraavaksi", @@ -1069,7 +1316,7 @@ "upload": "Siirrä palvelimelle", "upload_concurrency": "Latausten samanaikaisuus", "upload_errors": "Lataus valmistui {count, plural, one {# virheen} other {# virheen}} kanssa. Päivitä sivu nähdäksesi ladatut tiedot.", - "upload_progress": "{remaining} jäljellä - {processed}/{total} käsitelty", + "upload_progress": "Jäljellä {remaining, number} - Käsitelty {processed, number}/{total, number}", "upload_skipped_duplicates": "Ohitettiin {count, plural, one {# kaksoiskappale} other {# kaksoiskappaletta}}", "upload_status_duplicates": "Kaksoiskappaleet", "upload_status_errors": "Virheet", @@ -1081,6 +1328,8 @@ "user": "Käyttäjä", "user_id": "Käyttäjän ID", "user_liked": "{user} tykkäsi {type, select, photo {kuvasta} video {videosta} asset {mediasta} other {tästä}}", + "user_purchase_settings": "Osta", + "user_purchase_settings_description": "Hallitse ostostasi", "user_role_set": "Tee käyttäjästä {user} {role}", "user_usage_detail": "Käyttäjän käytön tiedot", "username": "Käyttäjänimi", @@ -1091,6 +1340,8 @@ "version": "Versio", "version_announcement_closing": "Ystäväsi Alex", "version_announcement_message": "Hei! Sovelluksen uusi versio on saatavilla. Käythän vilkaisemassa julkaisun tiedot ja varmistathan, että docker-compose.yml ja .env määritykset ovat ajan tasalla. Näin varmistat järjestelmän toimivuuden, varsinkin jos käytät WatchToweria tai muuta automaattista päivitysjärjestelmää.", + "version_history": "Versiohistoria", + "version_history_item": "Asennettu {version} päivänä {date}", "video": "Video", "video_hover_setting": "Toista esikatselun video kun kursori viedään sen päälle", "video_hover_setting_description": "Toista videon esikatselukuva kun kursori on kuvan päällä. Vaikka toiminto on pois käytöstä, toiston voi aloittaa viemällä kursori toistokuvakkeen päälle.", @@ -1100,6 +1351,7 @@ "view_album": "Näytä albumi", "view_all": "Näytä kaikki", "view_all_users": "Näytä kaikki käyttäjät", + "view_in_timeline": "Näytä aikajanalla", "view_links": "Näytä linkit", "view_next_asset": "Näytä seuraava", "view_previous_asset": "Näytä edellinen", diff --git a/web/src/lib/i18n/fr.json b/i18n/fr.json similarity index 87% rename from web/src/lib/i18n/fr.json rename to i18n/fr.json index 5cae4b6ecf..781f801bb9 100644 --- a/web/src/lib/i18n/fr.json +++ b/i18n/fr.json @@ -14,7 +14,7 @@ "add_a_name": "Ajouter un nom", "add_a_title": "Ajouter un titre", "add_exclusion_pattern": "Ajouter un schéma d'exclusion", - "add_import_path": "Ajouter un chemin d'import", + "add_import_path": "Ajouter un chemin à importer", "add_location": "Ajouter un lieu", "add_more_users": "Ajouter plus d'utilisateurs", "add_partner": "Ajouter un partenaire", @@ -28,6 +28,7 @@ "added_to_favorites_count": "{count, number} ajouté(s) aux favoris", "admin": { "add_exclusion_pattern_description": "Ajouter des schémas d'exclusion. Les caractères génériques *, ** et ? sont pris en charge. Pour ignorer tous les fichiers dans un répertoire nommé « Raw », utilisez « **/Raw/** ». Pour ignorer tous les fichiers se terminant par « .tif », utilisez « **/*.tif ». Pour ignorer un chemin absolu, utilisez « /chemin/à/ignorer/** ».", + "asset_offline_description": "Ce média de la bibliothèque externe n'est plus présent sur le disque et a été déplacé vers la corbeille. Si le fichier a été déplacé dans la bibliothèque, vérifiez votre chronologie pour le nouveau média correspondant. Pour restaurer ce média, veuillez vous assurer que le chemin du fichier ci-dessous peut être accédé par Immich et lancez l'analyse de la bibliothèque.", "authentication_settings": "Paramètres d'authentification", "authentication_settings_description": "Gérer le mot de passe, la délégation d'authentification OAuth et d'autres paramètres d'authentification", "authentication_settings_disable_all": "Êtes-vous sûr de vouloir désactiver toutes les méthodes de connexion ? La connexion sera complètement désactivée.", @@ -41,6 +42,7 @@ "confirm_email_below": "Pour confirmer, tapez « {email} » ci-dessous", "confirm_reprocess_all_faces": "Êtes-vous sûr de vouloir retraiter tous les visages ? Cela effacera également les personnes déjà identifiées.", "confirm_user_password_reset": "Êtes-vous sûr de vouloir réinitialiser le mot de passe de {user} ?", + "create_job": "Créer une tâche", "crontab_guru": "Générateur de règles Cron", "disable_login": "Désactiver la connexion", "disabled": "Désactivé", @@ -49,27 +51,37 @@ "external_library_created_at": "Bibliothèque externe (créée le {date})", "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. « Tout » (re)traite tous les médias. « Manquant » met en file d'attente les médias qui n'ont pas encore été traités. Les visages détectés seront mis en file d'attente pour la reconnaissance faciale une fois la détection des visages terminée, les regroupant en personnes existantes ou nouvelles.", - "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. « Tout » (re)regroupe tous les visages. « Manquant » met en file d'attente les visages auxquels aucune personne n'a été attribuée.", + "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. « Rafraichir» (re)traite tous les médias. « Réinitialise» met en file d'attente les médias qui n'ont pas encore été traités. Les visages détectés seront mis en file d'attente pour la reconnaissance faciale une fois la détection des visages terminée, les regroupant en personnes existantes ou nouvelles.", + "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.", "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", + "image_format": "Format", "image_format_description": "WebP produit des fichiers plus petits que JPEG, mais son encodage est plus lent.", "image_prefer_embedded_preview": "Préférer l'aperçu intégré", "image_prefer_embedded_preview_setting_description": "Utiliser les miniatures intégrées dans les photos au format RAW comme entrées pour le traitement d'image quand elles sont disponibles. Cela peut donner des couleurs plus justes pour certaines images, mais la qualité des miniatures est dépendant de l'appareil photo et l'image peut avoir des artéfacts de compression.", "image_prefer_wide_gamut": "Préférer une gamme de couleurs étendue", - "image_prefer_wide_gamut_setting_description": "Utiliser Display P3 pour les miniatures. Cela préserve mieux la vibrance des images avec des espaces colorimétriques étendus, mais les images peuvent apparaître différemment sur les anciens appareils avec une ancienne version du navigateur. Conserver les images sRGB en sRGB pour éviter les décalages de couleur.", + "image_prefer_wide_gamut_setting_description": "Utiliser Display P3 pour les miniatures. Cela préserve mieux la vivacité des images avec des espaces colorimétriques étendus, mais les images peuvent apparaître différemment sur les anciens appareils avec une ancienne version du navigateur. Conserver les images sRGB en sRGB pour éviter les décalages de couleur.", + "image_preview_description": "Image de taille moyenne avec métadonnées retirées, utilisée lors de la visualisation d'un seul média et pour l'apprentissage automatique", "image_preview_format": "Format des aperçus", + "image_preview_quality_description": "Qualité de l'aperçu : de 1 à 100. Une valeur plus élevée produit de meilleurs résultats, mais elle produit des fichiers plus volumineux et peut réduire la réactivité de l'application. Une valeur trop basse peut affecter la qualité de l'apprentissage automatique.", "image_preview_resolution": "Résolution des aperçus", "image_preview_resolution_description": "Utilisé lors de l'affichage d'une seule photo et pour l'apprentissage automatique. Des résolutions plus élevées peuvent préserver plus de détails mais prennent plus de temps à encoder, ont des tailles de fichiers plus importantes et peuvent réduire la réactivité de l'application.", + "image_preview_title": "Paramètres de prévisualisation", "image_quality": "Qualité", "image_quality_description": "Qualité d'image de 1 à 100. Une valeur plus élevée offre une meilleure qualité mais produit des fichiers plus volumineux. Cette option affecte les images d'aperçu et de miniature.", + "image_resolution": "Résolution", + "image_resolution_description": "Les résolutions plus élevées permettent de préserver davantage de détails, mais l'encodage est plus long, les fichiers sont plus volumineux et la réactivité de l'application peut s'en trouver réduite.", "image_settings": "Paramètres d'image", - "image_settings_description": "Gérer la qualité et la résolution des images générées", + "image_settings_description": "Gestion de la qualité et résolution des images générées", + "image_thumbnail_description": "Petite vignette avec métadonnées retirées, utilisée lors de la visualisation de groupes de photos comme sur la vue chronologique principale", "image_thumbnail_format": "Format des miniatures", + "image_thumbnail_quality_description": "Qualité des vignettes : de 1 à 100. Une valeur élevée produit de meilleurs résultats, mais elle produit des fichiers plus volumineux et peut réduire la réactivité de l'application.", "image_thumbnail_resolution": "Résolution des miniatures", - "image_thumbnail_resolution_description": "Utilisée lors du visionnage de groupes de photos (vue principale, albums, etc.). Une résolution plus élevée préserve davantage de détails, mais est plus longue à encoder, produit des fichiers plus lourds, et peut réduire la réactivité de l'application.", + "image_thumbnail_resolution_description": "Utilisée lors du visionnage de groupes de photos (vue chronologique principale, albums, etc.). Une résolution plus élevée préserve davantage de détails, mais est plus longue à encoder, produit des fichiers plus lourds, et peut réduire la réactivité de l'application.", + "image_thumbnail_title": "Paramètres des vignettes", "job_concurrency": "{job} : nombre de tâches simultanées", + "job_created": "Tâche créée", "job_not_concurrency_safe": "Cette tâche ne peut pas être exécutée en multitâche de façon sûre.", "job_settings": "Paramètres des tâches", "job_settings_description": "Gestion des tâches simultanées", @@ -79,14 +91,14 @@ "library_created": "Bibliothèque créée : {library}", "library_cron_expression": "Expression Cron", "library_cron_expression_description": "Réglez l'intervalle d'analyse en utilisant le format cron. Pour plus d'informations, veuillez consulter par exemple Crontab Guru", - "library_cron_expression_presets": "Expressions Cron enregistrées", + "library_cron_expression_presets": "Préréglages d'expressions Cron", "library_deleted": "Bibliothèque supprimée", "library_import_path_description": "Spécifier un dossier à importer. Ce dossier, y compris les sous-dossiers, sera analysé à la recherche d'images et de vidéos.", "library_scanning": "Analyse périodique", "library_scanning_description": "Configurer l'analyse périodique de la bibliothèque", "library_scanning_enable_description": "Activer l'analyse périodique de la bibliothèque", "library_settings": "Bibliothèque externe", - "library_settings_description": "Gérer les paramètres de 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_watching_enable_description": "Surveiller les modifications de fichiers dans les bibliothèques externes", "library_watching_settings": "Surveillance de bibliothèque (EXPÉRIMENTAL)", @@ -129,25 +141,30 @@ "map_enable_description": "Activer la carte", "map_gps_settings": "Paramètres de la carte et GPS", "map_gps_settings_description": "Gérer les paramètres de la Carte & GPS", + "map_implications": "La carte repose sur un service de tuiles externe (tiles.immich.cloud)", "map_light_style": "Thème clair", "map_manage_reverse_geocoding_settings": "Gérer les paramètres de géocodage inversé", "map_reverse_geocoding": "Géocodage inversé", "map_reverse_geocoding_enable_description": "Activer le géocodage inversé", "map_reverse_geocoding_settings": "Paramètres de géocodage inversé", - "map_settings": "Paramètres de la carte", + "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", "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 et la résolution", + "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", + "metadata_faces_import_setting_description": "Importation de visages à partir des données EXIF des images et de fichiers sidecar", + "metadata_settings": "Paramètres des métadonnées", + "metadata_settings_description": "Gestion des paramètres de métadonnées", "migration_job": "Migration", "migration_job_description": "Migration des miniatures pour les médias et les visages vers la dernière structure de dossiers", "no_paths_added": "Aucun chemin n'a été ajouté", "no_pattern_added": "Aucun schéma d'exclusion n'a été ajouté", - "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment téléversés, exécutez la commande", + "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment envoyés, exécutez la commande", "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", - "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", + "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", "notification_email_host_description": "Hôte du serveur de messagerie électronique (par exemple, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer les erreurs de certificat", "notification_email_ignore_certificate_errors_description": "Ignorer les erreurs de validation du certificat TLS (non recommandé)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL de l'émetteur", "oauth_mobile_redirect_uri": "URI de redirection mobile", "oauth_mobile_redirect_uri_override": "Remplacer l'URI de redirection mobile", - "oauth_mobile_redirect_uri_override_description": "Activer lorsque 'app.immich:/' est une URI de redirection non valide.", + "oauth_mobile_redirect_uri_override_description": "Activer quand le fournisseur d'OAuth ne permet pas un URI mobile, comme '{callback} '", "oauth_profile_signing_algorithm": "Algorithme de signature de profil", "oauth_profile_signing_algorithm_description": "Algorithme utilisé pour signer le profil utilisateur.", "oauth_scope": "Portée", @@ -193,19 +210,22 @@ "password_settings": "Connexion par mot de passe", "password_settings_description": "Gérer les paramètres de connexion par mot de passe", "paths_validated_successfully": "Tous les chemins ont été validés avec succès", + "person_cleanup_job": "Nettoyage des personnes", "quota_size_gib": "Taille du quota (Go)", "refreshing_all_libraries": "Actualisation de toutes les bibliothèques", "registration": "Enregistrement de l'administrateur", "registration_description": "Puisque vous êtes le premier utilisateur sur le système, vous serez désigné en tant qu'administrateur et responsable des tâches administratives, et vous pourrez alors créer d'autres utilisateurs.", - "removing_offline_files": "Suppression des fichiers hors ligne", + "removing_deleted_files": "Suppression des fichiers hors ligne", "repair_all": "Réparer tout", "repair_matched_items": "{count, plural, one {# Élément correspondant} other {# Éléments correspondants}}", "repaired_items": "{count, plural, one {# Élément corrigé} other {# Éléments corrigés}}", "require_password_change_on_login": "Demander à l'utilisateur de changer son mot de passe lors de sa première connexion", "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", "scanning_library_for_changed_files": "Recherche de fichiers modifiés dans la bibliothèque", "scanning_library_for_new_files": "Recherche de nouveaux fichiers dans la bibliothèque", + "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)://", @@ -223,16 +243,17 @@ "storage_template_hash_verification_enabled": "Vérification du hachage activée", "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 téléchargé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 téléchargés, exécutez la tâche {job}.", + "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_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.", "storage_template_path_length": "Limite approximative de la longueur du chemin : {length, number}/{limit, number}", "storage_template_settings": "Modèle de stockage", - "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média téléversé", + "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", + "tag_cleanup_job": "Nettoyage des étiquettes", "theme_custom_css_settings": "CSS personnalisé", "theme_custom_css_settings_description": "Les feuilles de style en cascade (CSS) permettent de personnaliser l'apparence d'Immich.", "theme_settings": "Paramètres du thème", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Accélération matérielle", "transcoding_hardware_acceleration_description": "Expérimental ; beaucoup plus rapide, mais aura une qualité inférieure pour un même débit binaire", "transcoding_hardware_decoding": "Décodage matériel", - "transcoding_hardware_decoding_setting_description": "S'applique uniquement à NVENC, QSV et RKMPP. Active l'accélération de bout en bout au lieu d'accélérer uniquement l'encodage. Peut ne pas fonctionner sur toutes les vidéos.", + "transcoding_hardware_decoding_setting_description": "Active l'accélération de bout en bout au lieu d'accélérer uniquement l'encodage. Peut ne pas fonctionner sur toutes les vidéos.", "transcoding_hevc_codec": "Codec HEVC", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Matériel préféré", "transcoding_preferred_hardware_device_description": "S'applique uniquement à VAAPI et QSV. Définit le nœud DRI utilisé pour le transcodage matériel.", "transcoding_preset_preset": "Présélection (-preset)", - "transcoding_preset_preset_description": "Vitesse de compression. Les préréglages les plus lents produisent des fichiers plus petits, et augmentent la qualité lorsque l'on vise un certain débit. Le codec vidéo VP9 ignore les vitesses supérieures à « rapide (faster) ».", + "transcoding_preset_preset_description": "Vitesse de compression. Les préréglages les plus lents produisent des fichiers plus petits, et augmentent la qualité lorsqu'un certain débit est défini. Le codec vidéo VP9 ignore les vitesses supérieures à « rapide (faster) ».", "transcoding_reference_frames": "Trames de référence", "transcoding_reference_frames_description": "Le nombre d'images à prendre en référence lors de la compression d'une image donnée. Des valeurs élevées améliorent l'efficacité de la compression, mais ralentissent l'encodage. 0 fixe cette valeur automatiquement.", "transcoding_required_description": "Seulement les vidéos dans un format non accepté", @@ -306,7 +327,8 @@ "trash_settings": "Corbeille", "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", - "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, téléchargements interrompus, ou abandons en raison d'un bug", + "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", + "user_cleanup_job": "Nettoyage des utilisateurs", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -314,13 +336,14 @@ "user_delete_immediately_checkbox": "Mise en file d'attente d'un utilisateur et de médias en vue d'une suppression immédiate", "user_management": "Gestion des utilisateurs", "user_password_has_been_reset": "Le mot de passe de l'utilisateur a été réinitialisé :", - "user_password_reset_description": "Veuillez saisir un mot de passe temporaire à l'utilisateur et informez-le qu'il devra le changer à sa première connexion.", + "user_password_reset_description": "Veuillez fournir le mot de passe temporaire à l'utilisateur et informez-le qu'il devra le changer à sa première connexion.", "user_restore_description": "Le compte de {user} sera restauré.", "user_restore_scheduled_removal": "Restaurer l'utilisateur - suppression programmée le {date, date, long}", "user_settings": "Paramètres utilisateur", "user_settings_description": "Gérer les paramètres utilisateur", "user_successfully_removed": "L'utilisateur {email} a bien été supprimé.", - "version_check_enabled_description": "Activer la vérification périodique de nouvelle version sur GitHub", + "version_check_enabled_description": "Activer la vérification périodique de nouvelle version", + "version_check_implications": "Le contrôle de version repose sur une communication périodique avec github.com", "version_check_settings": "Vérification de la version", "version_check_settings_description": "Gérer la vérification de nouvelle version d'Immich", "video_conversion_job": "Transcodage des vidéos", @@ -336,7 +359,8 @@ "album_added": "Album ajouté", "album_added_notification_setting_description": "Recevoir une notification par courriel lorsque vous êtes ajouté(e) à un album partagé", "album_cover_updated": "Couverture de l'album mise à jour", - "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?\nSi cet album est partagé, les autres utilisateurs ne pourront plus y accéder.", + "album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer l'album {album} ?", + "album_delete_confirmation_description": "Si cet album est partagé, les autres utilisateurs ne pourront plus y accéder.", "album_info_updated": "Détails de l'album mis à jour", "album_leave": "Quitter l'album ?", "album_leave_confirmation": "Êtes-vous sûr de vouloir quitter l'album {album} ?", @@ -359,7 +383,8 @@ "allow_dark_mode": "Autoriser le mode sombre", "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 aux utilisateurs non connectés de téléverser", + "allow_public_user_to_upload": "Permettre l'envoi aux utilisateurs non connectés", + "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.", "api_key_empty": "Le nom de votre clé API ne doit pas être vide", @@ -381,10 +406,11 @@ "asset_has_unassigned_faces": "Le média a des visages non assignés", "asset_hashing": "Hachage...", "asset_offline": "Média hors ligne", - "asset_offline_description": "Ce média est hors ligne. Immich ne peut pas accéder à son emplacement physique. Veuillez vous assurez que le média est disponible, puis relancez l'analyse de la bibliothèque.", + "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_uploaded": "Téléversé", - "asset_uploading": "Chargement...", + "asset_skipped_in_trash": "À la corbeille", + "asset_uploaded": "Envoyé", + "asset_uploading": "Envoi...", "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", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "{count, plural, one {# média déplacé} other {# médias déplacés}} dans la corbeille", "assets_permanently_deleted_count": "{count, plural, one {# média supprimé} other {# médias supprimés}} définitivement", "assets_removed_count": "{count, plural, one {# média supprimé} other {# médias supprimés}}", - "assets_restore_confirmation": "Êtes-vous sûr de vouloir restaurer tous vos médias de la corbeille ? Vous ne pouvez pas annuler cette action !", + "assets_restore_confirmation": "Êtes-vous sûr de vouloir restaurer tous vos médias de la corbeille ? Vous ne pouvez pas annuler cette action ! Notez que les médias hors ligne ne peuvent être restaurés de cette façon.", "assets_restored_count": "{count, plural, one {# média restauré} other {# médias restaurés}}", "assets_trashed_count": "{count, plural, one {# média} other {# médias}} mis à la corbeille", "assets_were_part_of_album_count": "{count, plural, one {Un média est} other {Des médias sont}} déjà dans l'album", @@ -405,6 +431,7 @@ "birthdate_saved": "Date de naissance sauvée avec succès", "birthdate_set_description": "La date de naissance est utilisée pour calculer l'âge de cette personne au moment où la photo a été prise.", "blurred_background": "Arrière-plan flouté", + "bugs_and_feature_requests": "Bugs & demandes d'évolutions", "build": "Version", "build_image": "Image de la version", "bulk_delete_duplicates_confirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, one {# doublon} other {# doublons}} ? Cette opération conservera le plus grand média de chaque groupe et supprimera définitivement tous les autres doublons. Vous ne pouvez pas annuler cette action !", @@ -441,13 +468,15 @@ "clear_all_recent_searches": "Supprimer les recherches récentes", "clear_message": "Effacer le message", "clear_value": "Effacer la valeur", + "clockwise": "Sens horaire", "close": "Fermer", "collapse": "Réduire", "collapse_all": "Tout réduire", - "color_theme": "Thème coloré", + "color": "Couleur", + "color_theme": "Thème de couleur", "comment_deleted": "Commentaire supprimé", "comment_options": "Options des commentaires", - "comments_and_likes": "Commentaires et réactions « j'aime »", + "comments_and_likes": "Commentaires et \"j'aime\"", "comments_are_disabled": "Les commentaires sont désactivés", "confirm": "Confirmer", "confirm_admin_password": "Confirmer le mot de passe Admin", @@ -477,6 +506,8 @@ "create_new_person": "Créer une nouvelle personne", "create_new_person_hint": "Attribuer les médias sélectionnés à une nouvelle personne", "create_new_user": "Créer un nouvel utilisateur", + "create_tag": "Créer une étiquette", + "create_tag_description": "Créer une nouvelle étiquette. Pour les étiquettes imbriquées, veuillez entrer le chemin complet de l'étiquette, y compris les caractères \"/\".", "create_user": "Créer un utilisateur", "created": "Créé", "current_device": "Appareil actuel", @@ -500,13 +531,17 @@ "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", "delete_shared_link": "Supprimer le lien partagé", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer l'étiquette {tagName} ?", "delete_user": "Supprimer l'utilisateur", "deleted_shared_link": "Lien partagé supprimé", + "deletes_missing_assets": "Supprimer les médias manquants du disque", "description": "Description", "details": "Détails", "direction": "Direction", "disabled": "Désactivé", "disallow_edits": "Ne pas autoriser les modifications", + "discord": "Discord", "discover": "Découvrir", "dismiss_all_errors": "Ignorer toutes les erreurs", "dismiss_error": "Ignorer l'erreur", @@ -515,13 +550,16 @@ "display_original_photos": "Afficher les photos originales", "display_original_photos_setting_description": "Préférer afficher la photo originale lors de la visualisation d'un média plutôt que sa miniature lorsque cela est possible. Cela peut entraîner des vitesses d'affichage plus lentes.", "do_not_show_again": "Ne plus afficher ce message", + "documentation": "Documentation", "done": "Terminé", "download": "Télécharger", + "download_include_embedded_motion_videos": "Vidéos embarquées", + "download_include_embedded_motion_videos_description": "Inclure des vidéos intégrées dans les photos de mouvement comme un fichier séparé", "download_settings": "Télécharger", "download_settings_description": "Gérer les paramètres de téléchargement des médias", "downloading": "Téléchargement", "downloading_asset_filename": "Téléchargement du média {filename}", - "drop_files_to_upload": "Déposer des fichiers n'importe où pour téléverser", + "drop_files_to_upload": "Déposez les fichiers n'importe où pour envoyer", "duplicates": "Doublons", "duplicates_description": "Examiner chaque groupe et indiquer s'il y a des doublons", "duration": "Durée", @@ -546,10 +584,15 @@ "edit_location": "Modifier la localisation", "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", + "edit_tag": "Modifier l'étiquette", "edit_title": "Modifier le title", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", "editor": "Editeur", + "editor_close_without_save_prompt": "Les changements ne seront pas enregistrés", + "editor_close_without_save_title": "Fermer l'éditeur ?", + "editor_crop_tool_h2_aspect_ratios": "Rapports hauteur/largeur", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Courriel", "empty": "", "empty_album": "Album vide", @@ -592,7 +635,7 @@ "failed_to_remove_product_key": "Échec de suppression de la clé du produit", "failed_to_stack_assets": "Impossible d'empiler les médias", "failed_to_unstack_assets": "Impossible de dépiler les médias", - "import_path_already_exists": "Ce chemin d'import existe déjà.", + "import_path_already_exists": "Ce chemin d'importation existe déjà.", "incorrect_email_or_password": "Courriel ou mot de passe incorrect", "paths_validation_failed": "Validation échouée pour {paths, plural, one {# un chemin} other {# plusieurs chemins}}", "profile_picture_transparent_pixels": "Les images de profil ne peuvent pas avoir de pixels transparents. Veuillez agrandir et/ou déplacer l'image.", @@ -602,7 +645,7 @@ "unable_to_add_assets_to_shared_link": "Impossible d'ajouter des médias au lien partagé", "unable_to_add_comment": "Impossible d'ajouter un commentaire", "unable_to_add_exclusion_pattern": "Impossible d'ajouter un schéma d'exclusion", - "unable_to_add_import_path": "Impossible d'ajouter un chemin d'import", + "unable_to_add_import_path": "Impossible d'ajouter le chemin d'importation", "unable_to_add_partners": "Impossible d'ajouter des partenaires", "unable_to_add_remove_archive": "Impossible {archived, select, true {de supprimer des médias de} other {d'ajouter des médias à}} l'archive", "unable_to_add_remove_favorites": "Impossible {favorite, select, true {d'ajouter des médias aux} other {de supprimer des médias des}} favoris", @@ -627,18 +670,19 @@ "unable_to_delete_asset": "Suppression du média impossible", "unable_to_delete_assets": "Erreur lors de la suppression des médias", "unable_to_delete_exclusion_pattern": "Suppression du modèle d'exclusion impossible", - "unable_to_delete_import_path": "Suppression du chemin d'import impossible", + "unable_to_delete_import_path": "Suppression du chemin d'importation impossible", "unable_to_delete_shared_link": "Suppression du lien de partage impossible", "unable_to_delete_user": "Suppression de l'utilisateur impossible", "unable_to_download_files": "Impossible de télécharger les fichiers", "unable_to_edit_exclusion_pattern": "Modification du modèle d'exclusion impossible", - "unable_to_edit_import_path": "Modification du chemin d'import impossible", + "unable_to_edit_import_path": "Modification du chemin d'importation impossible", "unable_to_empty_trash": "Impossible de vider la corbeille", "unable_to_enter_fullscreen": "Mode plein écran indisponible", "unable_to_exit_fullscreen": "Sortie du mode plein écran impossible", "unable_to_get_comments_number": "Impossible d'obtenir le nombre de commentaires", "unable_to_get_shared_link": "Échec de la récupération du lien partagé", "unable_to_hide_person": "Impossible de cacher la personne", + "unable_to_link_motion_video": "Impossible de lier la photo animée", "unable_to_link_oauth_account": "Impossible de lier le compte OAuth", "unable_to_load_album": "Impossible de charger l'album", "unable_to_load_asset_activity": "Impossible de charger l'activité du média", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Impossible de supprimer la clé API", "unable_to_remove_assets_from_shared_link": "Impossible de supprimer des médias du lien partagé", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Impossible de supprimer les fichiers hors ligne", "unable_to_remove_library": "Impossible de supprimer la bibliothèque", - "unable_to_remove_offline_files": "Impossible de supprimer les fichiers hors ligne", "unable_to_remove_partner": "Impossible de supprimer le partenaire", "unable_to_remove_reaction": "Impossible de supprimer la réaction", "unable_to_remove_user": "", @@ -679,14 +723,15 @@ "unable_to_submit_job": "Impossible d'exécuter la tâche", "unable_to_trash_asset": "Impossible de mettre le média à la corbeille", "unable_to_unlink_account": "Impossible de détacher le compte", + "unable_to_unlink_motion_video": "Impossible de détacher la photo animée", "unable_to_update_album_cover": "Impossible de mettre à jour la couverture de l'album", "unable_to_update_album_info": "Impossible de mettre à jour les informations de l'album", "unable_to_update_library": "Impossible de mettre à jour la bibliothèque", "unable_to_update_location": "Impossible de mettre à jour la localisation", "unable_to_update_settings": "Impossible de mettre à jour les paramètres", - "unable_to_update_timeline_display_status": "Impossible de mettre à jour le statut d'affichage de la timeline", + "unable_to_update_timeline_display_status": "Impossible de mettre à jour le statut d'affichage de la vue chronologique", "unable_to_update_user": "Impossible de mettre à jour l'utilisateur", - "unable_to_upload_file": "Impossible de téléverser le fichier" + "unable_to_upload_file": "Impossible d'envoyer le fichier" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -699,11 +744,12 @@ "expired": "Expiré", "expires_date": "Expire le {date}", "explore": "Explorer", + "explorer": "Explorateur", "export": "Exporter", "export_as_json": "Exporter en JSON", "extension": "Extension", "external": "Externe", - "external_libraries": "Bibliothèques externes", + "external_libraries": "Bibliothèques ext.", "face_unassigned": "Non attribué", "failed_to_get_people": "Impossible d'obtenir les personnes", "favorite": "Favori", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Photo de la personne mise à jour", "featurecollection": "", + "features": "Fonctionnalités", + "features_setting_description": "Gérer les fonctionnalités de l'application", "file_name": "Nom du fichier", "file_name_or_extension": "Nom du fichier ou extension", "filename": "Nom du fichier", @@ -720,6 +768,8 @@ "filter_people": "Filtrer les personnes", "find_them_fast": "Pour les retrouver rapidement par leur nom", "fix_incorrect_match": "Corriger une association incorrecte", + "folders": "Dossiers", + "folders_feature_description": "Parcourir l'affichage par dossiers pour les photos et les vidéos sur le système de fichiers", "force_re-scan_library_files": "Forcer la réactualisation de tous les fichiers de la bibliothèque", "forward": "Avant", "general": "Général", @@ -761,7 +811,7 @@ "immich_web_interface": "Interface Web Immich", "import_from_json": "Importer depuis un fichier JSON", "import_path": "Chemin d'importation", - "in_albums": "Dans {count, plural, one {# un album} other {# des albums}}", + "in_albums": "Dans {count, plural, one {# album} other {# albums}}", "in_archive": "Dans les archives", "include_archived": "Inclure les archives", "include_shared_albums": "Inclure les albums partagés", @@ -819,6 +869,7 @@ "license_trial_info_4": "Pensez à acheter une licence pour soutenir le développement du service", "light": "Clair", "like_deleted": "Réaction « j'aime » supprimée", + "link_motion_video": "Lier la photo animée", "link_options": "Options de lien", "link_to_oauth": "Lien au service OAuth", "linked_oauth_account": "Compte OAuth rattaché", @@ -837,6 +888,7 @@ "look": "Regarder", "loop_videos": "Vidéos en boucle", "loop_videos_description": "Activer pour voir la vidéo en boucle dans le lecteur détaillé.", + "main_branch_warning": "Vous utilisez une version de développement. Nous vous recommandons fortement d'utiliser une version stable !", "make": "Marque", "manage_shared_links": "Gérer les liens partagés", "manage_sharing_with_partners": "Gérer le partage avec les partenaires", @@ -887,10 +939,10 @@ "no_albums_with_name_yet": "Il semble que vous n'ayez pas encore d'albums avec ce nom.", "no_albums_yet": "Il semble que vous n'ayez pas encore d'album.", "no_archived_assets_message": "Archiver des photos et vidéos pour les masquer dans votre bibliothèque", - "no_assets_message": "CLIQUER ICI POUR IMPORTER VOTRE PREMIÈRE PHOTO", + "no_assets_message": "CLIQUER ICI POUR ENVOYER VOTRE PREMIÈRE PHOTO", "no_duplicates_found": "Aucun doublon n'a été trouvé.", "no_exif_info_available": "Aucune information exif disponible", - "no_explore_results_message": "Importer plus de photos pour explorer votre collection.", + "no_explore_results_message": "Envoyez plus de photos pour explorer votre collection.", "no_favorites_message": "Ajouter des photos et vidéos à vos favoris pour les retrouver plus rapidement", "no_libraries_message": "Créer une bibliothèque externe pour voir vos photos et vidéos dans un autre espace de stockage", "no_name": "Pas de nom", @@ -899,25 +951,28 @@ "no_results_description": "Essayez un synonyme ou un mot-clé plus général", "no_shared_albums_message": "Créer un album pour partager vos photos et vidéos avec les personnes de votre réseau", "not_in_any_album": "Dans aucun album", - "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà importés, lancer la", + "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà envoyés, lancer la", "note_unlimited_quota": "Note : Saisir 0 pour définir un quota illimité", "notes": "Notes", "notification_toggle_setting_description": "Activer les notifications par courriel", "notifications": "Notifications", "notifications_setting_description": "Gérer les notifications", "oauth": "OAuth", + "official_immich_resources": "Ressources Immich officielles", "offline": "Hors ligne", "offline_paths": "Chemins hors ligne", "offline_paths_description": "Ces résultats peuvent être causés par la suppression manuelle de fichiers qui n'étaient pas dans une bibliothèque externe.", "ok": "Ok", "oldest_first": "Anciens en premier", "onboarding": "Accueil", + "onboarding_privacy_description": "Les fonctions suivantes (optionnelles) dépendent de services externes et peuvent être désactivées à tout moment dans les paramètres d'administration.", "onboarding_theme_description": "Choisissez un thème de couleur pour votre instance. Vous pouvez le changer plus tard dans vos paramètres.", "onboarding_welcome_description": "Mettons votre instance en place avec quelques paramètres communs.", "onboarding_welcome_user": "Bienvenue {user}", "online": "En ligne", "only_favorites": "Uniquement les favoris", "only_refreshes_modified_files": "Actualise les fichiers modifiés uniquement", + "open_in_map_view": "Montrer sur la carte", "open_in_openstreetmap": "Ouvrir dans OpenStreetMap", "open_the_search_filters": "Ouvrir les filtres de recherche", "options": "Options", @@ -952,6 +1007,7 @@ "pending": "En attente", "people": "Personnes", "people_edits_count": "{count, plural, one {# personne éditée} other {# personnes éditées}}", + "people_feature_description": "Parcourir les photos et vidéos groupées par personnes", "people_sidebar_description": "Afficher le menu Personnes dans la barre latérale", "perform_library_tasks": "", "permanent_deletion_warning": "Avertissement avant suppression définitive", @@ -984,6 +1040,7 @@ "previous_memory": "Souvenir précédent", "previous_or_next_photo": "Photo précédente ou suivante", "primary": "Primaire", + "privacy": "Vie privée", "profile_image_of_user": "Image de profil de {user}", "profile_picture_set": "Photo de profil définie.", "public_album": "Album public", @@ -1021,6 +1078,10 @@ "purchase_server_title": "Serveur", "purchase_settings_server_activated": "La clé du produit pour le Serveur est gérée par l'administrateur", "range": "", + "rating": "Étoile d'évaluation", + "rating_clear": "Effacer l'évaluation", + "rating_count": "{count, plural, one {# étoile} other {# étoiles}}", + "rating_description": "Afficher l'évaluation EXIF dans le panneau d'information", "raw": "", "reaction_options": "Options de réaction", "read_changelog": "Lire les changements", @@ -1032,11 +1093,13 @@ "recent_searches": "Recherches récentes", "refresh": "Actualiser", "refresh_encoded_videos": "Actualiser les vidéos encodées", + "refresh_faces": "Mettre à jour les visages", "refresh_metadata": "Actualiser les métadonnées", "refresh_thumbnails": "Actualiser les vignettes", "refreshed": "Actualisé", - "refreshes_every_file": "Actualise tous les fichiers", + "refreshes_every_file": "Actualise tous les fichiers (existants et nouveaux)", "refreshing_encoded_video": "Actualisation de la vidéo encodée", + "refreshing_faces": "Actualiser les visages", "refreshing_metadata": "Actualisation des métadonnées", "regenerating_thumbnails": "Régénération des vignettes", "remove": "Supprimer", @@ -1044,15 +1107,16 @@ "remove_assets_shared_link_confirmation": "Êtes-vous sûr de vouloir supprimer {count, plural, one {# média} other {# médias}} de ce lien partagé ?", "remove_assets_title": "Supprimer les médias ?", "remove_custom_date_range": "Supprimer la plage de date personnalisée", + "remove_deleted_assets": "Supprimer les fichiers hors ligne", "remove_from_album": "Supprimer de l'album", "remove_from_favorites": "Supprimer des favoris", "remove_from_shared_link": "Supprimer des liens partagés", - "remove_offline_files": "Supprimer les fichiers hors ligne", "remove_user": "Supprimer l'utilisateur", "removed_api_key": "Clé API supprimée : {name}", "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_tagged_assets": "Tag supprimé de {count, plural, one {# média} other {# médias}}", "rename": "Renommer", "repair": "Réparer", "repair_no_results_message": "Les fichiers non importés ou absents s'afficheront ici", @@ -1084,6 +1148,7 @@ "say_something": "Réagir", "scan_all_libraries": "Analyser toutes les bibliothèques", "scan_all_library_files": "Analyser tous les fichiers", + "scan_library": "Analyser", "scan_new_library_files": "Analyser les nouveaux fichiers", "scan_settings": "Paramètres d'analyse", "scanning_for_album": "Recherche d'albums en cours...", @@ -1099,9 +1164,12 @@ "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_settings": "Paramètres de recherche", "search_state": "Rechercher par état/région...", + "search_tags": "Recherche d'étiquettes...", "search_timezone": "Rechercher par fuseau horaire...", "search_type": "Rechercher par type", "search_your_photos": "Rechercher vos photos", @@ -1143,6 +1211,7 @@ "shared_by_user": "Partagé par {user}", "shared_by_you": "Partagé par vous", "shared_from_partner": "Photos de {partner}", + "shared_link_options": "Options de lien partagé", "shared_links": "Liens partagés", "shared_photos_and_videos_count": "{assetCount, plural, other {# photos et vidéos partagées.}}", "shared_with_partner": "Partagé avec {partner}", @@ -1151,13 +1220,14 @@ "sharing_sidebar_description": "Afficher un lien vers Partage dans la barre latérale", "shift_to_permanent_delete": "appuyez sur ⇧ pour supprimer définitivement le média", "show_album_options": "Afficher les options de l'album", + "show_albums": "Montrer les albums", "show_all_people": "Montrer toutes les personnes", "show_and_hide_people": "Afficher / Masquer les personnes", "show_file_location": "Afficher l'emplacement du fichier", "show_gallery": "Afficher la gallerie", "show_hidden_people": "Afficher les personnes masquées", - "show_in_timeline": "Afficher dans la chronologie", - "show_in_timeline_setting_description": "Afficher les photos et vidéos de cet utilisateur dans votre timeline", + "show_in_timeline": "Afficher dans la vue chronologique", + "show_in_timeline_setting_description": "Afficher les photos et vidéos de cet utilisateur dans votre vue chronologique", "show_keyboard_shortcuts": "Afficher les raccourcis clavier", "show_metadata": "Afficher les métadonnées", "show_or_hide_info": "Afficher ou masquer les informations", @@ -1165,13 +1235,18 @@ "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_slideshow_transition": "Afficher la transition du diaporama", "show_supporter_badge": "Badge de contributeur", "show_supporter_badge_description": "Afficher le badge de contributeur", "shuffle": "Mélanger", + "sidebar": "Barre latérale", + "sidebar_display_description": "Afficher un lien vers la vue dans la barre latérale", "sign_out": "Déconnexion", "sign_up": "S'enregistrer", "size": "Taille", "skip_to_content": "Passer", + "skip_to_folders": "Passer vers les dossiers", + "skip_to_tags": "Passer vers les étiquettes", "slideshow": "Diaporama", "slideshow_settings": "Paramètres du diaporama", "sort_albums_by": "Trier les albums par...", @@ -1183,6 +1258,8 @@ "sort_title": "Titre", "source": "Source", "stack": "Empiler", + "stack_duplicates": "Empiler les doublons", + "stack_select_one_photo": "Sélectionnez une photo principale pour la pile", "stack_selected_photos": "Empiler les photos sélectionnées", "stacked_assets_count": "{count, plural, one {# média empilé} other {# médias empilés}}", "stacktrace": "Trace de la pile", @@ -1200,22 +1277,36 @@ "submit": "Soumettre", "suggestions": "Suggestions", "sunrise_on_the_beach": "Aurore sur la plage", - "swap_merge_direction": "Changer la direction de fusion", + "support": "Support", + "support_and_feedback": "Support & Retours", + "support_third_party_description": "Votre installation d'Immich est packagée via une application tierce. Si vous rencontrez des anomalies, elles peuvent venir de ce packaging tiers, merci de créer les anomalies avec ces tiers en premier lieu en utilisant les liens ci-dessous.", + "swap_merge_direction": "Inverser la direction de fusion", "sync": "Synchroniser", + "tag": "Tag", + "tag_assets": "Taguer les médias", + "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_updated": "Étiquette mise à jour : {tag}", + "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", + "tags": "Étiquettes", "template": "Modèle", "theme": "Thème", "theme_selection": "Sélection du thème", "theme_selection_description": "Ajuster automatiquement le thème clair ou sombre via les préférences système", "they_will_be_merged_together": "Elles seront fusionnées ensemble", + "third_party_resources": "Ressources tierces", "time_based_memories": "Souvenirs basés sur la date", "timezone": "Fuseau horaire", "to_archive": "Archiver", "to_change_password": "Modifier le mot de passe", "to_favorite": "Ajouter aux favoris", "to_login": "Se connecter", + "to_parent": "Aller au dossier parent", + "to_root": "Vers la racine", "to_trash": "Corbeille", "toggle_settings": "Inverser les paramètres", - "toggle_theme": "Changer le thème", + "toggle_theme": "Inverser le thème sombre", "toggle_visibility": "Modifier la visibilité", "total_usage": "Utilisation globale", "trash": "Corbeille", @@ -1234,9 +1325,11 @@ "unknown_album": "", "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é", "unnamed_album": "Album sans nom", + "unnamed_album_delete_confirmation": "Êtes-vous sûr de vouloir supprimer cet album ?", "unnamed_share": "Partage sans nom", "unsaved_change": "Modification non enregistrée", "unselect_all": "Annuler la sélection", @@ -1244,18 +1337,18 @@ "unstack": "Désempiler", "unstacked_assets_count": "{count, plural, one {# média dépilé} other {# médias dépilés}}", "untracked_files": "Fichiers non suivis", - "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, de téléchargements interrompus ou laissés pour compte à cause d'un bug", + "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, d'envois interrompus ou laissés pour compte à cause d'un bug", "up_next": "Suite", "updated_password": "Mot de passe mis à jour", - "upload": "Téléverser", - "upload_concurrency": "Envoi simultané", - "upload_errors": "Le téléversement s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload": "Envoyer", + "upload_concurrency": "Envois simultanés", + "upload_errors": "L'envoi s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias envoyés.", "upload_progress": "{remaining, number} restant(s) - {processed, number} traité(s)/{total, number}", "upload_skipped_duplicates": "{count, plural, one {# doublon ignoré} other {# doublons ignorés}}", "upload_status_duplicates": "Doublons", "upload_status_errors": "Erreurs", - "upload_status_uploaded": "Téléversé", - "upload_success": "Téléversement réussi. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload_status_uploaded": "Envoyé", + "upload_success": "Envoi réussi. Rafraîchir la page pour voir les nouveaux médias envoyés.", "url": "URL", "usage": "Utilisation", "use_custom_date_range": "Utilisez une plage de date personnalisée à la place", @@ -1276,6 +1369,8 @@ "version": "Version", "version_announcement_closing": "Ton ami, Alex", "version_announcement_message": "Bonjour, il y a une nouvelle version de l'application. Prenez le temps de consulter les notes de version et assurez-vous que vos fichiers docker-compose.yml et .env sont à jour pour éviter toute erreur de configuration, surtout si vous utilisez WatchTower ou tout autre mécanisme qui gère la mise à jour de votre application automatiquement.", + "version_history": "Historique de version", + "version_history_item": "Version {version} installée le {date}", "video": "Vidéo", "video_hover_setting": "Lire la miniature des vidéos au survol", "video_hover_setting_description": "Jouer la prévisualisation vidéo au survol. Si désactivé, la lecture peut quand même être démarrée en survolant le bouton Play.", @@ -1285,6 +1380,7 @@ "view_album": "Afficher l'album", "view_all": "Voir tout", "view_all_users": "Voir tous les utilisateurs", + "view_in_timeline": "Voir dans la vue chronologique", "view_links": "Voir les liens", "view_next_asset": "Voir le média suivant", "view_previous_asset": "Voir le média précédent", diff --git a/web/src/lib/i18n/he.json b/i18n/he.json similarity index 89% rename from web/src/lib/i18n/he.json rename to i18n/he.json index bcd35d4dda..7cb896f1f0 100644 --- a/web/src/lib/i18n/he.json +++ b/i18n/he.json @@ -25,12 +25,13 @@ "add_to_shared_album": "הוסף לאלבום משותף", "added_to_archive": "נוסף לארכיון", "added_to_favorites": "נוסף למועדפים", - "added_to_favorites_count": "{count} נוספו למועדפים", + "added_to_favorites_count": "{count, number} נוספו למועדפים", "admin": { "add_exclusion_pattern_description": "הוסף דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", השתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", השתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, השתמש ב \"**/נתיב/להתעלמות\".", - "authentication_settings": "הגדרות אימות", - "authentication_settings_description": "נהל סיסמה, OAuth, והגדרות אימות אחרות", - "authentication_settings_disable_all": "האם את/ה בטוח/ה שברצונך להשבית את כל שיטות ההתחברות? כניסה למערכת תהיה מושבתת לחלוטין.", + "asset_offline_description": "נכס ספרייה חיצונית זה לא נמצא יותר בדיסק והועבר לאשפה. אם הקובץ הועבר מתוך הספרייה, בדוק את ציר הזמן שלך עבור הנכס המקביל החדש. כדי לשחזר נכס זה, נא לוודא ש-Immich יכול לגשת אל נתיב הקובץ למטה וסרוק מחדש את הספרייה.", + "authentication_settings": "הגדרות התחברות", + "authentication_settings_description": "נהל סיסמה, OAuth, והגדרות התחברות אחרות", + "authentication_settings_disable_all": "האם ברצונך להשבית את כל שיטות ההתחברות? כניסה למערכת תהיה מושבתת לחלוטין.", "authentication_settings_reenable": "כדי לאפשר מחדש, השתמש בפקודת שרת.", "background_task_job": "משימות רקע", "check_all": "סמן הכל", @@ -41,6 +42,7 @@ "confirm_email_below": "כדי לאשר, יש להקליד \"{email}\" למטה", "confirm_reprocess_all_faces": "האם את/ה בטוח/ה שברצונך לעבד מחדש את כל הפנים? זה גם ינקה אנשים בעלי שם.", "confirm_user_password_reset": "האם את/ה בטוח/ה שברצונך לאפס את הסיסמה של המשתמש {user}?", + "create_job": "צור עבודה", "crontab_guru": "Crontab Guru", "disable_login": "השבת כניסה", "disabled": "מושבת", @@ -49,27 +51,37 @@ "external_library_created_at": "ספרייה חיצונית (נוצרה ב-{date})", "external_library_management": "ניהול ספרייה חיצונית", "face_detection": "איתור פנים", - "face_detection_description": "אתר את הפנים בנכסים באמצעות למידת מכונה. עבור סרטונים, רק התמונה הממוזערת נלקחת בחשבון. \"הכל\" מעבד (מחדש) את כל הנכסים. \"חסרים\" מוסיף לתור נכסים שלא עובדו עדיין. לאחר שאיתור הפנים הושלם, פנים שאותרו יעמדו בתור לזיהוי פנים המשייך אותן לאנשים קיימים או חדשים.", - "facial_recognition_job_description": "קבץ פנים שאותרו לתוך אנשים. שלב זה מורץ לאחר השלמת איתור פנים. \"הכל\" מקבץ (מחדש) את כל הפרצופים. \"חסרים\" מוסיף לתור פנים שלא הוקצה להם אדם.", + "face_detection_description": "אתר את הפנים בנכסים באמצעות למידת מכונה. עבור סרטונים, רק התמונה הממוזערת נלקחת בחשבון. \"רענון\" מעבד (מחדש) את כל הנכסים. \"איפוס\" מנקה בנוסף את כל נתוני הפנים הנוכחיים. \"חסרים\" מוסיף לתור נכסים שלא עובדו עדיין. לאחר שאיתור הפנים הושלם, פנים שאותרו יעמדו בתור לזיהוי פנים המשייך אותן לאנשים קיימים או חדשים.", + "facial_recognition_job_description": "קבץ פנים שאותרו לתוך אנשים. שלב זה מורץ לאחר השלמת איתור פנים. \"איפוס\" מקבץ (מחדש) את כל הפרצופים. \"חסרים\" מוסיף לתור פנים שלא הוקצה להם אדם.", "failed_job_command": "הפקודה {command} נכשלה עבור המשימה: {job}", "force_delete_user_warning": "אזהרה: פעולה זו תסיר מיד את המשתמש ואת כל הנכסים. לא ניתן לבטל פעולה זו והקבצים לא ניתנים לשחזור.", "forcing_refresh_library_files": "כפיית רענון של כל קבצי הספרייה", + "image_format": "פורמט", "image_format_description": "WebP מפיק קבצים קטנים יותר מ JPEG, אך הוא איטי יותר לקידוד.", "image_prefer_embedded_preview": "העדף תצוגה מקדימה מוטמעת", "image_prefer_embedded_preview_setting_description": "השתמש בתצוגות מקדימות מוטמעות בתמונות RAW כקלט לעיבוד תמונה כאשר זמינות. זה יכול להפיק צבעים מדויקים יותר עבור תמונות מסוימות, אבל האיכות של התצוגה המקדימה היא תלוית מצלמה ולתמונה עשויים להיות יותר פגמי דחיסה.", "image_prefer_wide_gamut": "העדף סולם צבעים רחב", "image_prefer_wide_gamut_setting_description": "השתמש ב-Display P3 לתמונות ממוזערות. זה משמר טוב יותר את החיוניות של תמונות עם מרחבי צבע רחבים, אבל תמונות עשויות להופיע אחרת במכשירים ישנים עם גרסת דפדפן ישנה. תמונות sRGB נשמרות כ-sRGB כדי למנוע שינויי צבע.", + "image_preview_description": "תמונה בגודל בינוני עם מטא-נתונים שהוסרו, משמשת בעת צפייה בנכס בודד ועבור למידת מכונה", "image_preview_format": "פורמט תצוגה מקדימה", + "image_preview_quality_description": "איכות תצוגה מקדימה בין 1-100. גבוה יותר הוא טוב יותר, אבל מייצר קבצים גדולים יותר ויכול להפחית את תגובתיות היישום. הגדרת ערך נמוך עשויה להשפיע על איכות תוצאות של למידת מכונה.", "image_preview_resolution": "רזולוציית תצוגה מקדימה", "image_preview_resolution_description": "משמש בעת צפייה בתמונה בודדת ועבור למידת מכונה. רזולוציות גבוהות יותר יכולות לשמר פירוט רב יותר אך לוקחות יותר זמן לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", + "image_preview_title": "הגדרות תצוגה מקדימה", "image_quality": "איכות", "image_quality_description": "איכות תמונה מ-1 עד 100. ערך גבוה יותר עדיף לאיכות אך מייצר קבצים גדולים יותר, אפשרות זו משפיעה על התצוגה המקדימה ותמונות ממוזערות.", + "image_resolution": "רזולוציה", + "image_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר ויכולות להפחית את תגובתיות היישום.", "image_settings": "הגדרות תמונה", "image_settings_description": "נהל את האיכות והרזולוציה של תמונות שנוצרו", + "image_thumbnail_description": "תמונה ממוזערת קטנה עם מטא-נתונים שהוסרו, משמשת בעת צפייה בקבוצות של תמונות כמו ציר הזמן הראשי", "image_thumbnail_format": "פורמט תמונה ממוזערת", + "image_thumbnail_quality_description": "איכות תמונה ממוזערת בין 1-100. גבוה יותר הוא טוב יותר, אבל מייצר קבצים גדולים יותר ויכול להפחית את תגובתיות היישום.", "image_thumbnail_resolution": "רזולוציית תמונה ממוזערת", "image_thumbnail_resolution_description": "משמש בעת צפייה בקבוצות של תמונות (ציר זמן ראשי, תצוגת אלבום וכו'). רזולוציות גבוהות יותר יכולות לשמר פירוט רב יותר אך לוקחות יותר זמן לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", + "image_thumbnail_title": "הגדרות תמונה ממוזערת", "job_concurrency": "בו-זמניות של {job}", + "job_created": "עבודה נוצרה", "job_not_concurrency_safe": "משימה זו אינה בטוחה במקביל.", "job_settings": "הגדרות משימה", "job_settings_description": "ניהול בו-זמניות של משימה", @@ -129,16 +141,21 @@ "map_enable_description": "אפשר תכונות מפה", "map_gps_settings": "הגדרות מפה & GPS", "map_gps_settings_description": "נהל הגדרות מפה & GPS (קידוד גאוגרפי הפוך)", + "map_implications": "תכונת המפה מסתמכת על שירות אריח חיצוני (tiles.immich.cloud)", "map_light_style": "עיצוב בהיר", "map_manage_reverse_geocoding_settings": "נהל הגדרות קידוד גאוגרפי הפוך", "map_reverse_geocoding": "קידוד גיאוגרפי הפוך", "map_reverse_geocoding_enable_description": "אפשר קידוד גיאוגרפי הפוך", "map_reverse_geocoding_settings": "הגדרות קידוד גיאוגרפי הפוך", - "map_settings": "הגדרות מפה", + "map_settings": "מפה", "map_settings_description": "נהל הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", - "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS ורזולוציה", + "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS, פנים ורזולוציה", + "metadata_faces_import_setting": "אפשר יבוא פנים", + "metadata_faces_import_setting_description": "יבא פנים מנתוני EXIF של תמונה ומקבצים נלווים", + "metadata_settings": "הגדרות מטא-נתונים", + "metadata_settings_description": "נהל הגדרות מטא-נתונים", "migration_job": "העברה", "migration_job_description": "העבר תמונות ממוזערות של נכסים ופנים למבנה התיקיות העדכני ביותר", "no_paths_added": "לא נוספו נתיבים", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "הערה: אי אפשר לשנות זאת מאוחר יותר!", "note_unlimited_quota": "הערה: הזן 0 עבור מכסת אחסון בלתי מוגבלת", "notification_email_from_address": "מכתובת", - "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", + "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", "notification_email_host_description": "מארח שרת הדוא\"ל (למשל smtp.immich.app)", "notification_email_ignore_certificate_errors": "התעלם משגיאות תעודה", "notification_email_ignore_certificate_errors_description": "התעלם משגיאות אימות תעודת TLS (לא מומלץ)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "כתובת אתר המנפיק", "oauth_mobile_redirect_uri": "URI להפניה מחדש בנייד", "oauth_mobile_redirect_uri_override": "עקיפת URI להפניה מחדש בנייד", - "oauth_mobile_redirect_uri_override_description": "אפשר כאשר 'app.immich:/' היא כתובת להפניה מחדש לא חוקית.", + "oauth_mobile_redirect_uri_override_description": "אפשר כאשר ספק OAuth לא מאפשר כתובת URI לנייד, כמו '{callback}'", "oauth_profile_signing_algorithm": "אלגוריתם חתימת פרופיל", "oauth_profile_signing_algorithm_description": "אלגוריתם המשמש לחתימה על פרופיל המשתמש.", "oauth_scope": "רמת הרשאה", @@ -193,19 +210,22 @@ "password_settings": "סיסמת התחברות", "password_settings_description": "נהל הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", + "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", "refreshing_all_libraries": "מרענן את כל הספריות", "registration": "רישום מנהל מערכת", "registration_description": "מכיוון שאתה המשתמש הראשון במערכת, אתה תוקצה כמנהל ואתה אחראי על משימות ניהול, ומשתמשים נוספים ייווצרו על ידך.", - "removing_offline_files": "הסרת קבצים לא מקוונים", + "removing_deleted_files": "הסרת קבצים לא מקוונים", "repair_all": "תקן הכל", "repair_matched_items": "{count, plural, one {פריט # תואם} other {# פריטים תואמים}}", "repaired_items": "{count, plural, one {פריט # תוקן} other {# פריטים תוקנו}}", "require_password_change_on_login": "דרוש מהמשתמש לשנות סיסמה בכניסה הראשונה", "reset_settings_to_default": "אפס הגדרות לברירת המחדל", "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", + "scanning_library": "סורק ספרייה", "scanning_library_for_changed_files": "סורק ספרייה לאיתור קבצים שהשתנו", "scanning_library_for_new_files": "סורק ספרייה לאיתור קבצים חדשים", + "search_jobs": "חיפוש עבודות...", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", + "tag_cleanup_job": "ניקוי תגים", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", @@ -250,6 +271,7 @@ "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_advanced_options_description": "אפשרויות שרוב המשתמשים לא צריכים לשנות", @@ -260,12 +282,12 @@ "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_constant_rate_factor_description": "רמת איכות וידאו. ערכים אופייניים הם הערך 23 עבור H.264, הערך 28 עבור HEVC, הערך 31 עבור VP9, והערך 35 עבור AV1. נמוך יותר הוא טוב יותר, אבל מייצר קבצים גדולים יותר.", "transcoding_disabled_description": "אין להמיר את הקידוד של שום סרטון, עלול לגרום לכך שהניגון לא יפעל במכשירים מסוימים", "transcoding_hardware_acceleration": "האצת חומרה", "transcoding_hardware_acceleration_description": "ניסיוני; המרה הרבה יותר מהירה, אבל תהיה באיכות נמוכה יותר באותו קצב סיביות", "transcoding_hardware_decoding": "פענוח חומרה", - "transcoding_hardware_decoding_setting_description": "חל רק על NVENC, QSV ו-RKMPP. מאפשר האצה מקצה לקצה במקום רק להאיץ קידוד. ייתכן שלא יפעל על כל הסרטונים.", + "transcoding_hardware_decoding_setting_description": "מאפשר האצה מקצה לקצה במקום רק האצת קידוד. ייתכן שלא יפעל על כל הסרטונים.", "transcoding_hevc_codec": "קידוד HEVC", "transcoding_max_b_frames": "B-פריימים מרביים", "transcoding_max_b_frames_description": "ערכים גבוהים יותר משפרים את יעילות הדחיסה, אך מאטים את הקידוד. ייתכן שלא יהיה תואם עם האצת חומרה במכשירים ישנים יותר. 0 משבית את B-פריימים, בעוד ש1- מגדיר את הערך זה באופן אוטומטי.", @@ -285,7 +307,7 @@ "transcoding_settings_description": "נהל את הרזולוציה ומידע הקידוד של קבצי הסרטונים", "transcoding_target_resolution": "רזולוציה יעד", "transcoding_target_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", - "transcoding_temporal_aq": "AQ זמני", + "transcoding_temporal_aq": "Temporal AQ", "transcoding_temporal_aq_description": "חל רק על NVENC. מגביר את האיכות של סצנות עם רמת פירוט גבוהה בהילוך איטי. ייתכן שלא יהיה תואם למכשירים ישנים יותר.", "transcoding_threads": "תהליכונים", "transcoding_threads_description": "ערכים גבוהים יותר מובילים לקידוד מהיר יותר, אך משאירים פחות מקום לשרת לעבד משימות אחרות בעודו פעיל. ערך זה לא אמור להיות יותר ממספר ליבות המעבד. ממקסם את הניצול אם מוגדר ל-0.", @@ -306,6 +328,7 @@ "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": "מספר הימים לאחר ההסרה עד מחיקה לצמיתות של החשבון והנכסים של המשתמש. משימת מחיקת המשתמש פועלת בחצות כדי לבדוק אם יש משתמשים שמוכנים למחיקה. שינויים בהגדרה זו יוערכו בביצוע הבא.", @@ -319,7 +342,8 @@ "user_settings": "הגדרות משתמש", "user_settings_description": "נהל הגדרות משתמש", "user_successfully_removed": "המשתמש {email} הוסר בהצלחה.", - "version_check_enabled_description": "אפשר בקשות רשת תקופתיות ל-GitHub כדי לבדוק אם יש מהדורות חדשות", + "version_check_enabled_description": "אפשר בדיקת גרסה", + "version_check_implications": "תכונת בדיקת הגרסה מסתמכת על תקשורת תקופתית עם github.com", "version_check_settings": "בדיקת גרסה", "version_check_settings_description": "הפעל/השבת את ההתראה על גרסה חדשה", "video_conversion_job": "המרת קידוד סרטונים", @@ -335,7 +359,8 @@ "album_added": "אלבום נוסף", "album_added_notification_setting_description": "קבלת הודעת דוא\"ל כאשר מוסיפים אותך לאלבום משותף", "album_cover_updated": "עטיפת האלבום עודכנה", - "album_delete_confirmation": "את/ה בטוח/ה שברצונך למחוק את האלבום {album}?\nאם האלבום הזה משותף, משתמשים אחרים לא יוכלו לגשת אליו יותר.", + "album_delete_confirmation": "את/ה בטוח/ה שברצונך למחוק את האלבום {album}?", + "album_delete_confirmation_description": "אם האלבום הזה משותף, משתמשים אחרים לא יוכלו לגשת אליו יותר.", "album_info_updated": "מידע האלבום עודכן", "album_leave": "לעזוב אלבום?", "album_leave_confirmation": "האם את/ה בטוח/ה שברצונך לעזוב את {album}?", @@ -359,6 +384,7 @@ "allow_edits": "אפשר עריכות", "allow_public_user_to_download": "אפשר למשתמש ציבורי להוריד", "allow_public_user_to_upload": "אפשר למשתמש ציבורי להעלות", + "anti_clockwise": "נגד כיוון השעון", "api_key": "מפתח API", "api_key_description": "הערך הזה יוצג רק פעם אחת. נא לוודא שהעתקת אותו לפני סגירת החלון.", "api_key_empty": "מפתח ה-API שלך לא אמור להיות ריק", @@ -380,8 +406,9 @@ "asset_has_unassigned_faces": "לנכס יש פנים שלא הוקצו", "asset_hashing": "מגבב...", "asset_offline": "נכס לא מקוון", - "asset_offline_description": "הנכס הזה אינו מקוון. Immich לא יכול לגשת למיקום הקובץ שלו. נא לוודא שהנכס זמין ואז סרוק מחדש את הספרייה.", + "asset_offline_description": "הנכס החיצוני הזה כבר לא נמצא בדיסק. אנא צור קשר עם מנהל Immich שלך לקבלת עזרה.", "asset_skipped": "דילג", + "asset_skipped_in_trash": "באשפה", "asset_uploaded": "הועלה", "asset_uploading": "מעלה...", "assets": "נכסים", @@ -393,7 +420,7 @@ "assets_moved_to_trash_count": "{count, plural, one {נכס # הועבר} other {# נכסים הועברו}} לאשפה", "assets_permanently_deleted_count": "{count, plural, one {נכס # נמחק} other {# נכסים נמחקו}} לצמיתות", "assets_removed_count": "{count, plural, one {נכס # הוסר} other {# נכסים הוסרו}}", - "assets_restore_confirmation": "האם את/ה בטוח/ה שברצונך לשחזר את כל הנכסים שבאשפה? את/ה לא יכול/ה לבטל את הפעולה הזו!", + "assets_restore_confirmation": "האם את/ה בטוח/ה שברצונך לשחזר את כל הנכסים שבאשפה? את/ה לא יכול/ה לבטל את הפעולה הזו! שים לב שלא ניתן לשחזר נכסים לא מקוונים בדרך זו.", "assets_restored_count": "{count, plural, one {נכס # שוחזר} other {# נכסים שוחזרו}}", "assets_trashed_count": "{count, plural, one {נכס # הושלך} other {# נכסים הושלכו}} לאשפה", "assets_were_part_of_album_count": "{count, plural, one {נכס היה} other {נכסים היו}} כבר חלק מהאלבום", @@ -404,6 +431,7 @@ "birthdate_saved": "תאריך לידה נשמר בהצלחה", "birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.", "blurred_background": "רקע מטושטש", + "bugs_and_feature_requests": "באגים & בקשות לתכונות", "build": "Build", "build_image": "Build Image", "bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!", @@ -440,9 +468,11 @@ "clear_all_recent_searches": "נקה את כל החיפושים האחרונים", "clear_message": "נקה הודעה", "clear_value": "נקה ערך", + "clockwise": "עם כיוון השעון", "close": "סגור", "collapse": "כווץ", "collapse_all": "כווץ הכל", + "color": "צבע", "color_theme": "צבע ערכת נושא", "comment_deleted": "תגובה נמחקה", "comment_options": "אפשרויות תגובה", @@ -476,6 +506,8 @@ "create_new_person": "צור אדם חדש", "create_new_person_hint": "הקצה את הנכסים שנבחרו לאדם חדש", "create_new_user": "צור משתמש חדש", + "create_tag": "צור תג", + "create_tag_description": "צור תג חדש. עבור תגים מקוננים, נא להזין את הנתיב המלא של התג כולל קווים נטויים.", "create_user": "צור משתמש", "created": "נוצר", "current_device": "מכשיר נוכחי", @@ -499,13 +531,17 @@ "delete_library": "מחק ספרייה", "delete_link": "מחק קישור", "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": "דיסקורד", "discover": "גלה", "dismiss_all_errors": "התעלם מכל השגיאות", "dismiss_error": "התעלם מהשגיאה", @@ -514,8 +550,11 @@ "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": "מוריד", @@ -545,10 +584,15 @@ "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": "", "empty_album": "אלבום ריק", @@ -638,6 +682,7 @@ "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": "לא ניתן לטעון את פעילות הנכס", @@ -654,8 +699,8 @@ "unable_to_remove_api_key": "לא ניתן להסיר מפתח API", "unable_to_remove_assets_from_shared_link": "לא ניתן להסיר נכסים מקישור משותף", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "לא ניתן להסיר קבצים לא מקוונים", "unable_to_remove_library": "לא ניתן להסיר ספרייה", - "unable_to_remove_offline_files": "לא ניתן להסיר קבצים לא מקוונים", "unable_to_remove_partner": "לא ניתן להסיר שותף", "unable_to_remove_reaction": "לא ניתן להסיר תגובה", "unable_to_remove_user": "", @@ -678,6 +723,7 @@ "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": "לא ניתן לעדכן ספרייה", @@ -698,6 +744,7 @@ "expired": "פג", "expires_date": "יפוג {date}", "explore": "חקור", + "explorer": "סייר", "export": "ייצוא", "export_as_json": "ייצוא כ-JSON", "extension": "סיומת", @@ -706,11 +753,13 @@ "face_unassigned": "לא מוקצה", "failed_to_get_people": "נכשל באחזור אנשים", "favorite": "מועדף", - "favorite_or_unfavorite_photo": "תמונה מועדפת או לא מועדפת", + "favorite_or_unfavorite_photo": "הוסף או הסר תמונה מהמועדפים", "favorites": "מועדפים", "feature": "", "feature_photo_updated": "תמונה מייצגת עודכנה", "featurecollection": "", + "features": "תכונות", + "features_setting_description": "נהל את תכונות היישום", "file_name": "שם הקובץ", "file_name_or_extension": "שם קובץ או סיומת", "filename": "שם קובץ", @@ -719,6 +768,8 @@ "filter_people": "סנן אנשים", "find_them_fast": "מצא אותם מהר לפי שם עם חיפוש", "fix_incorrect_match": "תקן התאמה שגויה", + "folders": "תיקיות", + "folders_feature_description": "עיון בתצוגת התיקייה עבור התמונות והסרטונים שבמערכת הקבצים", "force_re-scan_library_files": "כפה סריקה מחדש של כל קבצי הספרייה", "forward": "קדימה", "general": "כללי", @@ -818,6 +869,7 @@ "license_trial_info_4": "אנא שקול לרכוש רישיון כדי לתמוך בפיתוח המתמשך של השירות", "light": "בהיר", "like_deleted": "לייק נמחק", + "link_motion_video": "קשר סרטון תנועה", "link_options": "אפשרויות קישור", "link_to_oauth": "קישור ל-OAuth", "linked_oauth_account": "חשבון OAuth מקושר", @@ -836,6 +888,7 @@ "look": "מראה", "loop_videos": "הפעלה חוזרת של סרטונים", "loop_videos_description": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים.", + "main_branch_warning": "את/ה משתמש/ת בגרסת פיתוח; אנחנו ממליצים בחום להשתמש בגרסה יציבה!", "make": "תוצרת", "manage_shared_links": "נהל קישורים משותפים", "manage_sharing_with_partners": "נהל שיתוף עם שותפים", @@ -872,6 +925,7 @@ "name": "שם", "name_or_nickname": "שם או כינוי", "never": "אף פעם", + "new_album": "אלבום חדש", "new_api_key": "מפתח API חדש", "new_password": "סיסמה חדשה", "new_person": "אדם חדש", @@ -904,18 +958,21 @@ "notifications": "התראות", "notifications_setting_description": "נהל התראות", "oauth": "OAuth", + "official_immich_resources": "משאבי Immich רשמיים", "offline": "לא מקוון", "offline_paths": "נתיבים לא מקוונים", "offline_paths_description": "תוצאות אלו עשויות להיות עקב מחיקה ידנית של קבצים שאינם חלק מספרייה חיצונית.", "ok": "בסדר", "oldest_first": "הישן ביותר ראשון", "onboarding": "היכרות", + "onboarding_privacy_description": "התכונות (האופציונליות) הבאות מסתמכות על שירותים חיצוניים, וניתנות לביטול בכל עת בהגדרות הניהול.", "onboarding_theme_description": "בחר/י את צבע ערכת הנושא עבור ההתקנה שלך. את/ה יכול/ה לשנות את זה מאוחר יותר בהגדרות שלך.", "onboarding_welcome_description": "בואו נכין את ההתקנה שלכם עם כמה הגדרות נפוצות.", "onboarding_welcome_user": "ברוכ/ה הבא/ה, {user}", "online": "מקוון", "only_favorites": "רק מועדפים", "only_refreshes_modified_files": "מרענן רק קבצים שהשתנו", + "open_in_map_view": "פתח בתצוגת מפה", "open_in_openstreetmap": "פתח ב-OpenStreetMap", "open_the_search_filters": "פתח את מסנני החיפוש", "options": "אפשרויות", @@ -950,6 +1007,7 @@ "pending": "ממתין", "people": "אנשים", "people_edits_count": "{count, plural, one {אדם # נערך} other {# אנשים נערכו}}", + "people_feature_description": "עיון בתמונות וסרטונים שקובצו על ידי אנשים", "people_sidebar_description": "הצג קישור אל אנשים בסרגל הצד", "perform_library_tasks": "", "permanent_deletion_warning": "אזהרת מחיקה לצמיתות", @@ -982,6 +1040,7 @@ "previous_memory": "זיכרון קודם", "previous_or_next_photo": "התמונה הקודמת או הבאה", "primary": "ראשי", + "privacy": "פרטיות", "profile_image_of_user": "תמונת פרופיל של {user}", "profile_picture_set": "תמונת פרופיל נבחרה.", "public_album": "אלבום ציבורי", @@ -992,7 +1051,7 @@ "purchase_activated_title": "המפתח שלך הופעל בהצלחה", "purchase_button_activate": "הפעל", "purchase_button_buy": "קנה", - "purchase_button_buy_immich": "קנה Immich", + "purchase_button_buy_immich": "קנה את Immich", "purchase_button_never_show_again": "לעולם אל תראה שוב", "purchase_button_reminder": "הזכר לי בעוד 30 יום", "purchase_button_remove_key": "הסר מפתח", @@ -1005,7 +1064,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": "עבור שרת", @@ -1019,6 +1078,10 @@ "purchase_server_title": "שרת", "purchase_settings_server_activated": "מפתח המוצר של השרת מנוהל על ידי מנהל המערכת", "range": "", + "rating": "דירוג כוכב", + "rating_clear": "נקה דירוג", + "rating_count": "{count, plural, one {כוכב #} other {# כוכבים}}", + "rating_description": "הצג את דירוג ה-EXIF בלוח המידע", "raw": "", "reaction_options": "אפשרויות הגבה", "read_changelog": "קרא את יומן השינויים", @@ -1030,11 +1093,13 @@ "recent_searches": "חיפושים אחרונים", "refresh": "רענן", "refresh_encoded_videos": "רענן סרטונים מקודדים", + "refresh_faces": "רענן פנים", "refresh_metadata": "רענן מטא-נתונים", "refresh_thumbnails": "רענן תמונות ממוזערות", "refreshed": "רוענן", - "refreshes_every_file": "מרענן כל קובץ", + "refreshes_every_file": "קורא מחדש את כל הקבצים הקיימים והחדשים", "refreshing_encoded_video": "מרענן סרטון מקודד", + "refreshing_faces": "מרענן פרצופים", "refreshing_metadata": "מרענן מטא-נתונים", "regenerating_thumbnails": "מחדש תמונות ממוזערות", "remove": "הסר", @@ -1042,15 +1107,16 @@ "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": "הסר מקישור משותף", - "remove_offline_files": "הסר קבצים לא מקוונים", "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": "קבצים חסרי מעקב וחסרים יופיעו כאן", @@ -1082,6 +1148,7 @@ "say_something": "תגיד/י משהו", "scan_all_libraries": "סרוק את כל הספריות", "scan_all_library_files": "סרוק מחדש את כל קבצי הספרייה", + "scan_library": "סרוק", "scan_new_library_files": "סרוק קבצי ספרייה חדשים", "scan_settings": "הגדרות סריקה", "scanning_for_album": "סורק אחר אלבום...", @@ -1089,7 +1156,7 @@ "search_albums": "חפש אלבומים", "search_by_context": "חפש לפי הקשר", "search_by_filename": "חיפוש לפי שם קובץ או סיומת", - "search_by_filename_example": "לדוגמא IMG_1234.JPG/PNG", + "search_by_filename_example": "לדוגמא IMG_1234.JPG או PNG", "search_camera_make": "חפש תוצרת מצלמה...", "search_camera_model": "חפש דגם מצלמה...", "search_city": "חפש עיר...", @@ -1097,9 +1164,12 @@ "search_for_existing_person": "חפש אדם קיים", "search_no_people": "אין אנשים", "search_no_people_named": "אין אנשים בשם \"{name}\"", + "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", + "search_settings": "הגדרות חיפוש", "search_state": "חפש מדינה...", + "search_tags": "חיפוש תגים...", "search_timezone": "חפש אזור זמן...", "search_type": "סוג חיפוש", "search_your_photos": "חפש בתמונות שלך", @@ -1141,6 +1211,7 @@ "shared_by_user": "משותף על ידי {user}", "shared_by_you": "משותף על ידך", "shared_from_partner": "תמונות מאת {partner}", + "shared_link_options": "אפשרויות קישור משותף", "shared_links": "קישורים משותפים", "shared_photos_and_videos_count": "{assetCount, plural, other {# תמונות וסרטונים משותפים.}}", "shared_with_partner": "משותף עם {partner}", @@ -1149,6 +1220,7 @@ "sharing_sidebar_description": "הצג קישור אל שיתוף בסרגל הצד", "shift_to_permanent_delete": "לחץ ⇧ כדי למחוק לצמיתות נכס", "show_album_options": "הצג אפשרויות אלבום", + "show_albums": "הצג אלבומים", "show_all_people": "הצג את כל האנשים", "show_and_hide_people": "הצג & הסתר אנשים", "show_file_location": "הצג את מיקום הקובץ", @@ -1163,13 +1235,18 @@ "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": "מיין אלבומים לפי...", @@ -1181,6 +1258,8 @@ "sort_title": "כותרת", "source": "מקור", "stack": "ערימה", + "stack_duplicates": "צור ערימת כפילויות", + "stack_select_one_photo": "בחר תמונה ראשית אחת עבור הערימה", "stack_selected_photos": "צור ערימת תמונות נבחרות", "stacked_assets_count": "{count, plural, one {נכס # נערם} other {# נכסים נערמו}}", "stacktrace": "Stacktrace", @@ -1197,28 +1276,42 @@ "storage_usage": "{used} בשימוש מתוך {available}", "submit": "שלח", "suggestions": "הצעות", - "sunrise_on_the_beach": "שקיעה על החוף (מומלץ לחפש באנגלית לתוצאות טובות יותר)", + "sunrise_on_the_beach": "Sunrise on the beach (מומלץ לחפש באנגלית לתוצאות טובות יותר)", + "support": "תמיכה", + "support_and_feedback": "תמיכה & משוב", + "support_third_party_description": "התקנת ה-Immich שלך נארזה על ידי צד שלישי. בעיות שאתה חווה עשויות להיגרם על ידי חבילה זו, אז בבקשה תעלה בעיות איתם ראשית כל באמצעות הקישורים למטה.", "swap_merge_direction": "החלף כיוון מיזוג", "sync": "סנכרן", + "tag": "תג", + "tag_assets": "תיוג נכסים", + "tag_created": "נוצר תג: {tag}", + "tag_feature_description": "עיון בתמונות וסרטונים שקובצו על ידי נושאי תג לוגיים", + "tag_not_found_question": "לא מצליח למצוא תג? צור תג חדש", + "tag_updated": "תג מעודכן: {tag}", + "tagged_assets": "תויגו {count, plural, one {נכס #} other {# נכסים}}", + "tags": "תגים", "template": "תבנית", "theme": "ערכת נושא", "theme_selection": "בחירת ערכת נושא", "theme_selection_description": "הגדר אוטומטית את ערכת הנושא לבהיר או כהה בהתבסס על העדפת המערכת של הדפדפן שלך", "they_will_be_merged_together": "הם יתמזגו יחד", + "third_party_resources": "משאבי צד שלישי", "time_based_memories": "זכרונות מבוססי זמן", "timezone": "אזור זמן", "to_archive": "העבר לארכיון", "to_change_password": "שנה סיסמה", "to_favorite": "מועדף", "to_login": "כניסה", + "to_parent": "לך להורה", + "to_root": "לשורש", "to_trash": "אשפה", "toggle_settings": "החלף מצב הגדרות", - "toggle_theme": "החלף מצב ערכת נושא", + "toggle_theme": "החלף ערכת נושא כהה", "toggle_visibility": "החלף נראות", "total_usage": "שימוש כולל", "trash": "אשפה", "trash_all": "העבר הכל לאשפה", - "trash_count": "{count} לאשפה", + "trash_count": "העבר לאשפה {count, number}", "trash_delete_asset": "העבר לאשפה/מחק נכס", "trash_no_results_message": "תמונות וסרטונים שהועברו לאשפה יופיעו כאן.", "trashed_items_will_be_permanently_deleted_after": "פריטים באשפה ימחקו לצמיתות לאחר {days, plural, one {יום #} other {# ימים}}.", @@ -1232,9 +1325,11 @@ "unknown_album": "אלבום לא ידוע", "unknown_year": "שנה לא ידועה", "unlimited": "בלתי מוגבל", + "unlink_motion_video": "בטל קישור סרטון תנועה", "unlink_oauth": "בטל קישור OAuth", "unlinked_oauth_account": "בוטל קישור חשבון OAuth", "unnamed_album": "אלבום ללא שם", + "unnamed_album_delete_confirmation": "את/ה בטוח/ה שברצונך למחוק את האלבום הזה?", "unnamed_share": "שיתוף ללא שם", "unsaved_change": "שינוי לא נשמר", "unselect_all": "בטל בחירה בהכל", @@ -1274,6 +1369,8 @@ "version": "גרסה", "version_announcement_closing": "החבר שלך, אלכס", "version_announcement_message": "הי חבר/ה, יש מהדורה חדשה של היישום, אנא קח/י את הזמן שלך לבקר ב הערות פרסום ולוודא שמבנה ה-docker-compose.yml, וה-.env שלך עדכני כדי למנוע תצורות שגויות, במיוחד אם את/ה משתמש/ת ב-WatchTower או בכל מנגנון שמטפל בעדכון היישום שלך באופן אוטומטי.", + "version_history": "היסטוריית גרסאות", + "version_history_item": "{version} הותקנה ב-{date}", "video": "סרטון", "video_hover_setting": "הפעל תצוגת סרטון מקדימה בעת ריחוף", "video_hover_setting_description": "הפעל תצוגת סרטון מקדימה כאשר העכבר מרחף מעל פריט. אפילו כשהגדרה זו מושבתת, ניתן להתחיל את הניגון על ידי ריחוף מעל סמל ההפעלה.", @@ -1283,6 +1380,7 @@ "view_album": "הצג אלבום", "view_all": "הצג הכל", "view_all_users": "הצג את כל המשתמשים", + "view_in_timeline": "ראה בציר הזמן", "view_links": "הצג קישורים", "view_next_asset": "הצג את הנכס הבא", "view_previous_asset": "הצג את הנכס הקודם", @@ -1298,5 +1396,5 @@ "years_ago": "לפני {years, plural, one {שנה #} other {# שנים}}", "yes": "כן", "you_dont_have_any_shared_links": "אין לך קישורים משותפים", - "zoom_image": "התקרב לתמונה" + "zoom_image": "זום לתמונה" } diff --git a/i18n/hi.json b/i18n/hi.json new file mode 100644 index 0000000000..58b99dbc3c --- /dev/null +++ b/i18n/hi.json @@ -0,0 +1,1168 @@ +{ + "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": "साझा एल्बम में जोड़ें", + "added_to_archive": "संग्रहीत कर दिया गया है", + "added_to_favorites": "पसंदीदा में जोड़ा गया", + "added_to_favorites_count": "पसंदीदा में {count, number} जोड़ा गया", + "admin": { + "add_exclusion_pattern_description": "बहिष्करण पैटर्न जोड़ें. *, **, और ? का उपयोग करके ग्लोबिंग करना समर्थित है। \"Raw\" नामक किसी भी निर्देशिका की सभी फ़ाइलों को अनदेखा करने के लिए, \"**/Raw/**\" का उपयोग करें। \".tif\" से समाप्त होने वाली सभी फ़ाइलों को अनदेखा करने के लिए, \"**/*.tif\" का उपयोग करें। किसी पूर्ण पथ को अनदेखा करने के लिए, \"/path/to/ignore/**\" का उपयोग करें।", + "authentication_settings": "प्रमाणीकरण सेटिंग्स", + "authentication_settings_description": "पासवर्ड, OAuth और अन्य प्रमाणीकरण सेटिंग्स प्रबंधित करें", + "authentication_settings_disable_all": "क्या आप वाकई सभी लॉगिन विधियों को अक्षम करना चाहते हैं? लॉगिन पूरी तरह से अक्षम कर दिया जाएगा।", + "authentication_settings_reenable": "पुनः सक्षम करने के लिए, Server Command का प्रयोग करे।", + "background_task_job": "पृष्ठभूमि कार्य", + "check_all": "सभी चेक करें", + "cleared_jobs": "{job}: के लिए कार्य साफ़ कर दिए गए", + "config_set_by_file": "Config वर्तमान में एक config फ़ाइल द्वारा सेट किया गया है", + "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} का पासवर्ड रीसेट करना चाहते हैं?", + "crontab_guru": "", + "disable_login": "लॉगिन अक्षम करें", + "disabled": "", + "duplicate_detection_job_description": "समान छवियों का पता लगाने के लिए संपत्तियों पर मशीन लर्निंग चलाएं। यह कार्यक्षमता स्मार्ट खोज पर निर्भर करती है", + "exclusion_pattern_description": "Exclusion पैटर्न आपको अपनी लाइब्रेरी को स्कैन करते समय फ़ाइलों और फ़ोल्डरों को अनदेखा करने देता है। यह उपयोगी है यदि आपके पास ऐसे फ़ोल्डर हैं जिनमें ऐसी फ़ाइलें हैं जिन्हें आप आयात नहीं करना चाहते हैं, जैसे RAW फ़ाइलें।", + "external_library_created_at": "बाहरी लाइब्रेरी ({date} को बनाई गई)", + "external_library_management": "बाहरी लाइब्रेरी प्रबंधन", + "face_detection": "चेहरे का पहचान", + "face_detection_description": "मशीन लर्निंग का उपयोग करके संपत्तियों में चेहरों का पता लगाएं। वीडियो के लिए, केवल थंबनेल पर विचार किया जाता है। \"सभी\" परिसंपत्तियों को (पुनः) संसाधित करता है। \"लापता\" उन परिसंपत्तियों को कतारबद्ध करता है जिन्हें अभी तक संसाधित नहीं किया गया है। फेस डिटेक्शन पूरा होने के बाद पहचाने गए चेहरों को चेहरे की पहचान के लिए कतारबद्ध किया जाएगा, उन्हें मौजूदा या नए लोगों में समूहित किया जाएगा।", + "facial_recognition_job_description": "समूह ने लोगों में चेहरों का पता लगाया। यह चरण फेस डिटेक्शन पूरा होने के बाद चलता है। \"सभी\" चेहरों को (पुनः) समूहित करता है। \"लापता\" कतार में वे चेहरे हैं जिनके लिए कोई व्यक्ति नियुक्त नहीं है।", + "failed_job_command": "कार्य {job} के लिए आदेश {command} विफल", + "force_delete_user_warning": "चेतावनी: इससे उपयोगकर्ता और सारा डेटा तुरंत हट जाएगा। इसे पूर्ववत नहीं किया जा सकता और फ़ाइलें पुनर्प्राप्त नहीं की जा सकतीं।", + "forcing_refresh_library_files": "सभी लाइब्रेरी फ़ाइलों को जबरन सामयिक करें", + "image_format_description": "वेबपी, जेपीईजी की तुलना में छोटी फ़ाइलें बनाता है, लेकिन एनकोड करने में धीमा है।", + "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_preview_format": "पूर्वावलोकन प्रारूप", + "image_preview_resolution": "पूर्वावलोकन रिज़ॉल्यूशन", + "image_preview_resolution_description": "एकल फ़ोटो देखते समय और मशीन लर्निंग के लिए उपयोग किया जाता है। उच्च रिज़ॉल्यूशन अधिक विवरण को संरक्षित कर सकता है लेकिन एन्कोड करने में अधिक समय लेता है, फ़ाइल आकार बड़ा होता है, और ऐप की प्रतिक्रियाशीलता कम हो सकती है।", + "image_quality": "गुणवत्ता", + "image_quality_description": "छवि गुणवत्ता 1-100 तक। उच्च गुणवत्ता बेहतर है लेकिन बड़ी फ़ाइलें बनाती है, यह विकल्प पूर्वावलोकन और थंबनेल छवियों को प्रभावित करता है।", + "image_settings": "छवि सेटिंग्स", + "image_settings_description": "उत्पन्न छवियों की गुणवत्ता और रिज़ॉल्यूशन प्रबंधित करें", + "image_thumbnail_format": "थंबनेल प्रारूप", + "image_thumbnail_resolution": "थंबनेल रिज़ॉल्यूशन", + "image_thumbnail_resolution_description": "फ़ोटो के समूह (मुख्य टाइमलाइन, एल्बम दृश्य, आदि) देखते समय उपयोग किया जाता है। उच्च रिज़ॉल्यूशन अधिक विवरण को संरक्षित कर सकता है लेकिन एन्कोड करने में अधिक समय लेता है, फ़ाइल आकार बड़ा होता है, और ऐप की प्रतिक्रियाशीलता कम हो सकती है।", + "job_concurrency": "{job} समरूपता", + "job_not_concurrency_safe": "यह कार्य (जॉब) समवर्ती-सुरक्षित नहीं है।", + "job_settings": "कार्य (जॉब) सेटिंग्स", + "job_settings_description": "कार्य (जॉब) समवर्तीता प्रबंधित करें", + "job_status": "कार्य (जॉब) स्थिति", + "jobs_delayed": "{jobCount, plural, other {# विलंबित}}", + "jobs_failed": "{jobCount, plural, other {# असफल}}", + "library_created": "निर्मित संग्रह: {library}", + "library_cron_expression": "क्रॉन व्यंजक", + "library_cron_expression_description": "क्रॉन प्रारूप का उपयोग करके स्कैनिंग अंतराल सेट करें। अधिक जानकारी के लिए कृपया उदाहरण के लिए Crontab Guru देखें", + "library_cron_expression_presets": "क्रॉन व्यंजक प्रीसेट", + "library_deleted": "संग्रह हटा दिया गया", + "library_import_path_description": "आयात करने के लिए एक फ़ोल्डर निर्दिष्ट करें। सबफ़ोल्डर्स सहित इस फ़ोल्डर को छवियों और वीडियो के लिए स्कैन किया जाएगा।", + "library_scanning": "सामयिक स्कैनिंग", + "library_scanning_description": "सामयिक लाइब्रेरी स्कैनिंग कॉन्फ़िगर करें", + "library_scanning_enable_description": "सामयिक लाइब्रेरी स्कैनिंग सक्षम करें", + "library_settings": "बाहरी संग्रह", + "library_settings_description": "बाहरी संग्रह सेटिंग प्रबंधित करें", + "library_tasks_description": "संग्रह कार्य निष्पादित करें", + "library_watching_enable_description": "एक्सटर्नल लाइब्रेरीज में बदलावों के लिए निगरानी रखें", + "library_watching_settings": "पुस्तकालय निगरानी (प्रायोगिक)", + "library_watching_settings_description": "परिवर्तित फ़ाइलों पर स्वचालित रूप से नज़र रखें", + "logging_enable_description": "लॉगिंग करने देना", + "logging_level_description": "सक्षम होने पर, किस लॉग स्तर का उपयोग करना है।", + "logging_settings": "लॉगिंग", + "machine_learning_clip_model": "क्लिप मॉडल", + "machine_learning_clip_model_description": "CLIP मॉडल का नाम यहां सूचीबद्ध है। ध्यान दें कि मॉडल बदलने पर आपको सभी छवियों के लिए 'स्मार्ट सर्च' जोब फिर से चलाना होगा।", + "machine_learning_duplicate_detection": "डुप्लिकेट का पता लगाना", + "machine_learning_duplicate_detection_enabled": "डुप्लिकेट पहचान सक्षम करें", + "machine_learning_duplicate_detection_enabled_description": "यदि अक्षम किया गया है, तो बिल्कुल समान चित्र अभी भी डी-डुप्लिकेट किया जाएगा।", + "machine_learning_duplicate_detection_setting_description": "संभावित डुप्लिकेट खोजने के लिए CLIP एम्बेडिंग का उपयोग करें", + "machine_learning_enabled": "मशीन लर्निंग सक्षम करें", + "machine_learning_enabled_description": "यदि अक्षम किया गया है, तो नीचे दी गई सेटिंग्स पर ध्यान दिए बिना सभी एमएल सुविधाएं अक्षम कर दी जाएंगी।", + "machine_learning_facial_recognition": "चेहरे की पहचान", + "machine_learning_facial_recognition_description": "छवियों में चेहरे का पता लगाना, पहचानना और समूह बनाना", + "machine_learning_facial_recognition_model": "चेहरे की पहचान मॉडल", + "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_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_smart_search": "स्मार्ट खोज", + "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": "लॉग सेटिंग प्रबंधित करें", + "map_dark_style": "डार्क शैली", + "map_enable_description": "मानचित्र सुविधाएँ सक्षम करें", + "map_gps_settings": "मानचित्र एवं जीपीएस सेटिंग्स", + "map_gps_settings_description": "मानचित्र और जीपीएस (रिवर्स जियोकोडिंग) सेटिंग्स प्रबंधित करें", + "map_light_style": "हल्की शैली", + "map_manage_reverse_geocoding_settings": "प्रबंधित करना रिवर्स जियोकोडिंग समायोजन", + "map_reverse_geocoding": "रिवर्स जियोकोडिंग", + "map_reverse_geocoding_enable_description": "रिवर्स जियोकोडिंग सक्षम करें", + "map_reverse_geocoding_settings": "जियोकोडिंग सेटिंग्स को उल्टा करें", + "map_settings": "मानचित्र सेटिंग", + "map_settings_description": "मानचित्र सेटिंग प्रबंधित करें", + "map_style_description": "style.json मैप थीम का URL", + "metadata_extraction_job": "मेटाडेटा निकालें", + "metadata_extraction_job_description": "प्रत्येक परिसंपत्ति से जीपीएस और रिज़ॉल्यूशन जैसी मेटाडेटा जानकारी निकालें", + "migration_job": "प्रवास", + "migration_job_description": "संपत्तियों और चेहरों के थंबनेल को नवीनतम फ़ोल्डर संरचना में माइग्रेट करें", + "no_paths_added": "कोई पथ नहीं जोड़ा गया", + "no_pattern_added": "कोई पैटर्न नहीं जोड़ा गया", + "note_apply_storage_label_previous_assets": "नोट: पहले अपलोड की गई संपत्तियों पर स्टोरेज लेबल लागू करने के लिए, चलाएँ", + "note_cannot_be_changed_later": "नोट: इसे बाद में बदला नहीं जा सकता!", + "note_unlimited_quota": "नोट: असीमित कोटा के लिए 0 दर्ज करें", + "notification_email_from_address": "इस पते से", + "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", + "notification_email_host_description": "ईमेल सर्वर का होस्ट (उदा. smtp.immitch.app)", + "notification_email_ignore_certificate_errors": "प्रमाणपत्र त्रुटियों पर ध्यान न दें", + "notification_email_ignore_certificate_errors_description": "टीएलएस प्रमाणपत्र सत्यापन त्रुटियों पर ध्यान न दें (अनुशंसित नहीं)", + "notification_email_password_description": "ईमेल सर्वर से प्रमाणीकरण करते समय उपयोग किया जाने वाला पासवर्ड", + "notification_email_port_description": "ईमेल सर्वर का पोर्ट (जैसे 25, 465, या 587)", + "notification_email_sent_test_email_button": "परीक्षण ईमेल भेजें और सहेजें", + "notification_email_setting_description": "ईमेल सूचनाएं भेजने के लिए सेटिंग्स", + "notification_email_test_email": "परीक्षण ईमेल भेजें", + "notification_email_test_email_failed": "परीक्षण ईमेल भेजने में विफल, अपने मूल्यों की जाँच करें", + "notification_email_test_email_sent": "{email} पर एक परीक्षण ईमेल भेजा गया है। कृपया अपना इनबॉक्स देखें।", + "notification_email_username_description": "ईमेल सर्वर से प्रमाणीकरण करते समय उपयोग किया जाने वाला उपयोगकर्ता नाम", + "notification_enable_email_notifications": "ईमेल सूचनाएं सक्षम करें", + "notification_settings": "अधिसूचना सेटिंग्स", + "notification_settings_description": "ईमेल सहित अधिसूचना सेटिंग्स प्रबंधित करें", + "oauth_auto_launch": "ऑटो लांच", + "oauth_auto_launch_description": "लॉगिन पृष्ठ पर नेविगेट करने पर OAuth लॉगिन प्रवाह स्वचालित रूप से प्रारंभ करें", + "oauth_auto_register": "ऑटो रजिस्टर", + "oauth_auto_register_description": "OAuth के साथ साइन इन करने के बाद स्वचालित रूप से नए उपयोगकर्ताओं को पंजीकृत करें", + "oauth_button_text": "टेक्स्ट बटन", + "oauth_client_id": "ग्राहक ID", + "oauth_client_secret": "ग्राहक गुप्त", + "oauth_enable_description": "OAuth से लॉगिन करें", + "oauth_issuer_url": "जारीकर्ता URL", + "oauth_mobile_redirect_uri": "मोबाइल रीडायरेक्ट यूआरआई", + "oauth_mobile_redirect_uri_override": "मोबाइल रीडायरेक्ट यूआरआई ओवरराइड", + "oauth_mobile_redirect_uri_override_description": "सक्षम करें जब 'app.immitch:/' एक अमान्य रीडायरेक्ट यूआरआई हो।", + "oauth_profile_signing_algorithm": "प्रोफ़ाइल हस्ताक्षर एल्गोरिथ्म", + "oauth_profile_signing_algorithm_description": "उपयोगकर्ता प्रोफ़ाइल पर हस्ताक्षर करने के लिए एल्गोरिदम का उपयोग किया जाता है।", + "oauth_scope": "स्कोप", + "oauth_settings": "ओऑथ", + "oauth_settings_description": "OAuth लॉगिन सेटिंग प्रबंधित करें", + "oauth_settings_more_details": "इस सुविधा के बारे में अधिक जानकारी के लिए, देखें डॉक्स।", + "oauth_signing_algorithm": "हस्ताक्षर एल्गोरिथ्म", + "oauth_storage_label_claim": "भंडारण लेबल का दावा", + "oauth_storage_label_claim_description": "इस दावे के मूल्य पर उपयोगकर्ता के भंडारण लेबल को स्वचालित रूप से सेट करें।", + "oauth_storage_quota_claim": "भंडारण कोटा का दावा", + "oauth_storage_quota_claim_description": "उपयोगकर्ता के संग्रहण कोटा को इस दावे के मूल्य पर स्वचालित रूप से सेट करें।", + "oauth_storage_quota_default": "डिफ़ॉल्ट संग्रहण कोटा (GiB)", + "oauth_storage_quota_default_description": "GiB में कोटा का उपयोग तब किया जाएगा जब कोई दावा प्रदान नहीं किया गया हो (असीमित कोटा के लिए 0 दर्ज करें)।", + "offline_paths": "ऑफ़लाइन पथ", + "offline_paths_description": "ये परिणाम उन फ़ाइलों को मैन्युअल रूप से हटाने के कारण हो सकते हैं जो बाहरी लाइब्रेरी का हिस्सा नहीं हैं।", + "password_enable_description": "ईमेल और पासवर्ड से लॉगिन करें", + "password_settings": "पासवर्ड लॉग इन", + "password_settings_description": "पासवर्ड लॉगिन सेटिंग प्रबंधित करें", + "paths_validated_successfully": "सभी पथ सफलतापूर्वक मान्य किए गए", + "quota_size_gib": "कोटा आकार (GiB)", + "refreshing_all_libraries": "सभी पुस्तकालयों को ताज़ा किया जा रहा है", + "registration": "व्यवस्थापक पंजीकरण", + "registration_description": "चूंकि आप सिस्टम पर पहले उपयोगकर्ता हैं, इसलिए आपको व्यवस्थापक के रूप में नियुक्त किया जाएगा और आप प्रशासनिक कार्यों के लिए जिम्मेदार होंगे, और अतिरिक्त उपयोगकर्ता आपके द्वारा बनाए जाएंगे।", + "removing_deleted_files": "ऑफ़लाइन फ़ाइलें हटाना", + "repair_all": "सभी की मरम्मत", + "require_password_change_on_login": "उपयोगकर्ता को पहले लॉगिन पर पासवर्ड बदलने की आवश्यकता है", + "reset_settings_to_default": "सेटिंग्स को डिफ़ॉल्ट पर रीसेट करें", + "reset_settings_to_recent_saved": "सेटिंग्स को हाल ही में सहेजी गई सेटिंग्स पर रीसेट करें", + "scanning_library_for_changed_files": "परिवर्तित फ़ाइलों के लिए लाइब्रेरी को स्कैन करना", + "scanning_library_for_new_files": "नई फ़ाइलों के लिए लाइब्रेरी को स्कैन करना", + "send_welcome_email": "स्वागत ईमेल भेजें", + "server_external_domain_settings": "बाहरी डोमेन", + "server_external_domain_settings_description": "सार्वजनिक साझा लिंक के लिए डोमेन, जिसमें http(s):// शामिल है", + "server_settings": "सर्वर सेटिंग्स", + "server_settings_description": "सर्वर सेटिंग्स प्रबंधित करें", + "server_welcome_message": "स्वागत संदेश", + "server_welcome_message_description": "एक संदेश जो लॉगिन पृष्ठ पर प्रदर्शित होता है।", + "sidecar_job": "साइडकार मेटाडेटा", + "sidecar_job_description": "फ़ाइल सिस्टम से साइडकार मेटाडेटा खोजें या सिंक्रनाइज़ करें", + "slideshow_duration_description": "प्रत्येक छवि को प्रदर्शित करने के लिए सेकंड की संख्या", + "smart_search_job_description": "स्मार्ट खोज का समर्थन करने के लिए संपत्तियों पर मशीन लर्निंग चलाएं", + "storage_template_date_time_description": "एसेट के निर्माण टाइमस्टैम्प का उपयोग दिनांक समय की जानकारी के लिए किया जाता है", + "storage_template_enable_description": "भंडारण टेम्पलेट इंजन सक्षम करें", + "storage_template_hash_verification_enabled": "हैश सत्यापन सक्षम किया गया", + "storage_template_hash_verification_enabled_description": "हैश सत्यापन सक्षम करता है, जब तक आप इसके निहितार्थों के बारे में निश्चित न हों, इसे अक्षम न करें", + "storage_template_migration": "भंडारण टेम्पलेट माइग्रेशन", + "storage_template_migration_job": "संग्रहण टेम्पलेट माइग्रेशन कार्य", + "storage_template_more_details": "इस सुविधा के बारे में अधिक जानकारी के लिए, देखें भंडारण टेम्पलेट और इसके आशय", + "storage_template_onboarding_description": "सक्षम होने पर, यह सुविधा उपयोगकर्ता द्वारा परिभाषित टेम्पलेट के आधार पर फ़ाइलों को स्वतः व्यवस्थित कर देगी। स्थिरता संबंधी समस्याओं के कारण यह सुविधा डिफ़ॉल्ट रूप से बंद कर दी गई है। अधिक जानकारी के लिए, कृपया दस्तावेज़ीकरण देखें।", + "storage_template_settings": "भंडारण टेम्पलेट", + "storage_template_settings_description": "अपलोड संपत्ति की फ़ोल्डर संरचना और फ़ाइल नाम प्रबंधित करें", + "system_settings": "प्रणाली व्यवस्था", + "theme_custom_css_settings": "कस्टम सीएसएस", + "theme_custom_css_settings_description": "कैस्केडिंग स्टाइल शीट्स इमिच के डिज़ाइन को अनुकूलित करने की अनुमति देती हैं।", + "theme_settings": "थीम सेटिंग", + "theme_settings_description": "इम्मीच वेब इंटरफ़ेस का अनुकूलन प्रबंधित करें", + "these_files_matched_by_checksum": "इन फ़ाइलों का मिलान उनके चेकसम से किया जाता है", + "thumbnail_generation_job": "थंबनेल उत्पन्न करें", + "thumbnail_generation_job_description": "प्रत्येक संपत्ति के लिए बड़े, छोटे और धुंधले थंबनेल, साथ ही प्रत्येक व्यक्ति के लिए थंबनेल बनाएं", + "transcode_policy_description": "", + "transcoding_acceleration_api": "त्वरण एपीआई", + "transcoding_acceleration_api_description": "एपीआई जो ट्रांसकोडिंग को तेज करने के लिए आपके डिवाइस के साथ इंटरैक्ट करेगा।", + "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU की आवश्यकता है)", + "transcoding_acceleration_qsv": "त्वरित सिंक (सातवीं पीढ़ी के इंटेल सीपीयू या बाद के संस्करण की आवश्यकता है)", + "transcoding_acceleration_rkmpp": "आरकेएमपीपी (केवल रॉकचिप एसओसी पर)", + "transcoding_acceleration_vaapi": "वीएएपीआई", + "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_advanced_options_description": "अधिकांश उपयोगकर्ताओं को विकल्प बदलने की आवश्यकता नहीं होनी चाहिए", + "transcoding_audio_codec": "ऑडियो कोडेक", + "transcoding_audio_codec_description": "ओपस उच्चतम गुणवत्ता वाला विकल्प है, लेकिन पुराने उपकरणों या सॉफ़्टवेयर के साथ इसकी अनुकूलता कम है।", + "transcoding_bitrate_description": "अधिकतम बिटरेट से अधिक या स्वीकृत प्रारूप में नहीं होने वाले वीडियो", + "transcoding_codecs_learn_more": "यहां प्रयुक्त शब्दावली के बारे में अधिक जानने के लिए, FFmpeg दस्तावेज़ देखें H.264 कोडेक, एचईवीसी कोडेक और VP9 कोडेक।", + "transcoding_constant_quality_mode": "लगातार गुणवत्ता मोड", + "transcoding_constant_quality_mode_description": "ICQ CQP से बेहतर है, लेकिन कुछ हार्डवेयर एक्सेलेरेशन डिवाइस इस मोड का समर्थन नहीं करते हैं।", + "transcoding_constant_rate_factor": "स्थिर दर कारक (-सीआरएफ)", + "transcoding_constant_rate_factor_description": "वीडियो गुणवत्ता स्तर।", + "transcoding_disabled_description": "किसी भी वीडियो को ट्रांसकोड न करें, इससे कुछ क्लाइंट पर प्लेबैक बाधित हो सकता है", + "transcoding_hardware_acceleration": "हार्डवेयर एक्सिलरेशन", + "transcoding_hardware_acceleration_description": "प्रायोगिक; बहुत तेजी से, लेकिन एक ही बिटरेट में कम गुणवत्ता होगी", + "transcoding_hardware_decoding": "हार्डवेयर डिकोडिंग", + "transcoding_hardware_decoding_setting_description": "केवल एनवीईएनसी, क्यूएसवी और आरकेएमपीपी पर लागू होता है।", + "transcoding_hevc_codec": "एचईवीसी कोडेक", + "transcoding_max_b_frames": "अधिकतम बी-फ्रेम", + "transcoding_max_b_frames_description": "उच्च मान संपीड़न दक्षता में सुधार करते हैं, लेकिन एन्कोडिंग को धीमा कर देते हैं।", + "transcoding_max_bitrate": "अधिकतम बिटरेट", + "transcoding_max_bitrate_description": "अधिकतम बिटरेट सेट करने से गुणवत्ता की मामूली लागत पर फ़ाइल आकार को अधिक पूर्वानुमानित बनाया जा सकता है।", + "transcoding_max_keyframe_interval": "अधिकतम मुख्यफ़्रेम अंतराल", + "transcoding_max_keyframe_interval_description": "मुख्यफ़्रेम के बीच अधिकतम फ़्रेम दूरी निर्धारित करता है।", + "transcoding_optimal_description": "लक्ष्य रिज़ॉल्यूशन से अधिक ऊंचे वीडियो या स्वीकृत प्रारूप में नहीं", + "transcoding_preferred_hardware_device": "पसंदीदा हार्डवेयर डिवाइस", + "transcoding_preferred_hardware_device_description": "केवल VAAPI और QSV पर लागू होता है।", + "transcoding_preset_preset": "प्रीसेट (-preset)", + "transcoding_preset_preset_description": "संपीड़न गति।", + "transcoding_reference_frames": "संदर्भ फ्रेम", + "transcoding_reference_frames_description": "किसी दिए गए फ़्रेम को संपीड़ित करते समय संदर्भित किए जाने वाले फ़्रेमों की संख्या।", + "transcoding_required_description": "केवल वे वीडियो जो स्वीकृत प्रारूप में नहीं हैं", + "transcoding_settings": "वीडियो ट्रांसकोडिंग सेटिंग्स", + "transcoding_settings_description": "वीडियो फ़ाइलों के रिज़ॉल्यूशन और एन्कोडिंग जानकारी को प्रबंधित करें", + "transcoding_target_resolution": "लक्ष्य संकल्प", + "transcoding_target_resolution_description": "उच्च रिज़ॉल्यूशन अधिक विवरण संरक्षित कर सकते हैं लेकिन एन्कोड करने में अधिक समय लेते हैं, फ़ाइल आकार बड़े होते हैं, और ऐप प्रतिक्रियाशीलता को कम कर सकते हैं।", + "transcoding_temporal_aq": "अस्थायी AQ", + "transcoding_temporal_aq_description": "केवल एनवीईएनसी पर लागू होता है।", + "transcoding_threads": "थ्रेड्स", + "transcoding_threads_description": "उच्च मान तेज़ एन्कोडिंग की ओर ले जाते हैं, लेकिन सक्रिय रहते हुए सर्वर के लिए अन्य कार्यों को संसाधित करने के लिए कम जगह छोड़ते हैं।", + "transcoding_tone_mapping": "टोन-मैपिंग", + "transcoding_tone_mapping_description": "एसडीआर में परिवर्तित होने पर एचडीआर वीडियो की उपस्थिति को संरक्षित करने का प्रयास।", + "transcoding_tone_mapping_npl": "टोन-मैपिंग एनपीएल", + "transcoding_tone_mapping_npl_description": "इस चमक के प्रदर्शन को सामान्य दिखाने के लिए रंगों को समायोजित किया जाएगा।", + "transcoding_transcode_policy": "ट्रांसकोड नीति", + "transcoding_transcode_policy_description": "किसी वीडियो को कब ट्रांसकोड किया जाना चाहिए, इसके लिए नीति।", + "transcoding_two_pass_encoding": "दो-पास एन्कोडिंग", + "transcoding_two_pass_encoding_setting_description": "बेहतर एन्कोडेड वीडियो बनाने के लिए दो पासों में ट्रांसकोड करें।", + "transcoding_video_codec": "वीडियो कोडेक", + "transcoding_video_codec_description": "VP9 में उच्च दक्षता और वेब अनुकूलता है, लेकिन ट्रांसकोड करने में अधिक समय लगता है।", + "trash_enabled_description": "ट्रैश सुविधाएँ सक्षम करें", + "trash_number_of_days": "दिनों की संख्या", + "trash_number_of_days_description": "संपत्तियों को स्थायी रूप से हटाने से पहले उन्हें कूड़ेदान में रखने के लिए दिनों की संख्या", + "trash_settings": "ट्रैश सेटिंग", + "trash_settings_description": "ट्रैश सेटिंग प्रबंधित करें", + "untracked_files": "ट्रैक न की गई फ़ाइलें", + "untracked_files_description": "इन फ़ाइलों को एप्लिकेशन द्वारा ट्रैक नहीं किया जाता है. वे असफल चालों, बाधित अपलोड या किसी बग के कारण पीछे छूट जाने का परिणाम हो सकते हैं", + "user_delete_delay_settings": "हटाने में देरी", + "user_delete_delay_settings_description": "किसी उपयोगकर्ता के खाते और संपत्तियों को स्थायी रूप से हटाने के लिए हटाने के बाद दिनों की संख्या।", + "user_delete_immediately_checkbox": "तत्काल विलोपन के लिए उपयोगकर्ता और परिसंपत्तियों को कतारबद्ध करें", + "user_management": "प्रयोक्ता प्रबंधन", + "user_password_has_been_reset": "उपयोगकर्ता का पासवर्ड रीसेट कर दिया गया है:", + "user_password_reset_description": "कृपया उपयोगकर्ता को अस्थायी पासवर्ड प्रदान करें और उन्हें सूचित करें कि उन्हें अपने अगले लॉगिन पर पासवर्ड बदलने की आवश्यकता होगी।", + "user_settings": "उपयोगकर्ता सेटिंग", + "user_settings_description": "उपयोगकर्ता सेटिंग प्रबंधित करें", + "version_check_enabled_description": "नई रिलीज़ की जाँच के लिए GitHub पर आवधिक अनुरोध सक्षम करें", + "version_check_settings": "संस्करण चेक", + "version_check_settings_description": "नए संस्करण अधिसूचना को सक्षम/अक्षम करें", + "video_conversion_job": "ट्रांसकोड वीडियो", + "video_conversion_job_description": "ब्राउज़रों और उपकरणों के साथ व्यापक अनुकूलता के लिए वीडियो ट्रांसकोड करें" + }, + "admin_email": "व्यवस्थापक ईमेल", + "admin_password": "व्यवस्थापक पासवर्ड", + "administration": "प्रशासन", + "advanced": "विकसित", + "album_added": "एल्बम जोड़ा गया", + "album_added_notification_setting_description": "जब आपको किसी साझा एल्बम में जोड़ा जाए तो एक ईमेल सूचना प्राप्त करें", + "album_cover_updated": "एल्बम कवर अपडेट किया गया", + "album_info_updated": "एल्बम की जानकारी अपडेट की गई", + "album_leave": "एल्बम छोड़ें?", + "album_name": "एल्बम का नाम", + "album_options": "एल्बम विकल्प", + "album_remove_user": "उपयोगकर्ता हटाएं?", + "album_share_no_users": "ऐसा लगता है कि आपने यह एल्बम सभी उपयोगकर्ताओं के साथ साझा कर दिया है या आपके पास साझा करने के लिए कोई उपयोगकर्ता नहीं है।", + "album_updated": "एल्बम अपडेट किया गया", + "album_updated_setting_description": "जब किसी साझा एल्बम में नई संपत्तियाँ हों तो एक ईमेल सूचना प्राप्त करें", + "album_with_link_access": "लिंक वाले किसी भी व्यक्ति को इस एल्बम में फ़ोटो और लोगों को देखने दें।", + "albums": "एलबम", + "all": "सभी", + "all_albums": "सभी एलबम", + "all_people": "सभी लोग", + "all_videos": "सभी वीडियो", + "allow_dark_mode": "डार्क मोड की अनुमति दें", + "allow_edits": "संपादन की अनुमति दें", + "allow_public_user_to_download": "सार्वजनिक उपयोगकर्ता को डाउनलोड करने की अनुमति दें", + "allow_public_user_to_upload": "सार्वजनिक उपयोगकर्ता को अपलोड करने की अनुमति दें", + "api_key": "एपीआई की", + "api_key_description": "यह की केवल एक बार दिखाई जाएगी। विंडो बंद करने से पहले कृपया इसे कॉपी करना सुनिश्चित करें।।", + "api_key_empty": "आपका एपीआई कुंजी नाम खाली नहीं होना चाहिए", + "api_keys": "एपीआई कीज", + "app_settings": "एप्लिकेशन सेटिंग", + "appears_in": "प्रकट होता है", + "archive": "संग्रहालय", + "archive_or_unarchive_photo": "फ़ोटो को संग्रहीत या असंग्रहीत करें", + "archive_size": "पुरालेख आकार", + "archive_size_description": "डाउनलोड के लिए संग्रह आकार कॉन्फ़िगर करें (GiB में)", + "archived": "", + "are_these_the_same_person": "क्या ये वही व्यक्ति हैं?", + "are_you_sure_to_do_this": "क्या आप वास्तव में इसे करना चाहते हैं?", + "asset_added_to_album": "एल्बम में जोड़ा गया", + "asset_adding_to_album": "एल्बम में जोड़ा जा रहा है..।", + "asset_description_updated": "संपत्ति विवरण अद्यतन कर दिया गया है", + "asset_has_unassigned_faces": "एसेट में अनिर्धारित चेहरे हैं", + "asset_hashing": "हैशिंग..।", + "asset_offline": "संपत्ति ऑफ़लाइन", + "asset_offline_description": "यह संपत्ति ऑफ़लाइन है।", + "asset_skipped": "छोड़ा गया", + "asset_uploaded": "अपलोड किए गए", + "asset_uploading": "अपलोड हो रहा है..।", + "assets": "संपत्तियां", + "assets_restore_confirmation": "क्या आप वाकई अपनी सभी नष्ट की गई संपत्तियों को पुनर्स्थापित करना चाहते हैं? आप इस क्रिया को पूर्ववत नहीं कर सकते!", + "authorized_devices": "अधिकृत उपकरण", + "back": "वापस", + "back_close_deselect": "वापस जाएँ, बंद करें, या अचयनित करें", + "backward": "पिछला", + "birthdate_saved": "जन्मतिथि सफलतापूर्वक सहेजी गई", + "birthdate_set_description": "जन्मतिथि का उपयोग फोटो के समय इस व्यक्ति की आयु की गणना करने के लिए किया जाता है।", + "blurred_background": "धुंधली पृष्ठभूमि", + "build": "निर्माण", + "build_image": "छवि बनाएँ", + "buy": "इम्मीच खरीदो", + "camera": "कैमरा", + "camera_brand": "कैमरा ब्रांड", + "camera_model": "कैमरा मॉडल", + "cancel": "रद्द करना", + "cancel_search": "खोज रद्द करें", + "cannot_merge_people": "लोगों का विलय नहीं हो सकता", + "cannot_undo_this_action": "आप इस क्रिया को पूर्ववत नहीं कर सकते!", + "cannot_update_the_description": "विवरण अद्यतन नहीं किया जा सकता", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "बदलाव दिनांक", + "change_expiration_time": "समाप्ति समय बदलें", + "change_location": "स्थान बदलें", + "change_name": "नाम परिवर्तन करें", + "change_name_successfully": "नाम सफलतापूर्वक बदलें", + "change_password": "पासवर्ड बदलें", + "change_password_description": "यह या तो पहली बार है जब आप सिस्टम में साइन इन कर रहे हैं या आपका पासवर्ड बदलने का अनुरोध किया गया है।", + "change_your_password": "अपना पासवर्ड बदलें", + "changed_visibility_successfully": "दृश्यता सफलतापूर्वक परिवर्तित", + "check_all": "सभी चेक करें", + "check_logs": "लॉग जांचें", + "choose_matching_people_to_merge": "मर्ज करने के लिए मिलते-जुलते लोगों को चुनें", + "city": "शहर", + "clear": "स्पष्ट", + "clear_all": "सभी साफ करें", + "clear_all_recent_searches": "सभी हालिया खोजें साफ़ करें", + "clear_message": "स्पष्ट संदेश", + "clear_value": "स्पष्ट मूल्य", + "close": "बंद", + "collapse": "गिर जाना", + "collapse_all": "सभी को संकुचित करें", + "color_theme": "रंग थीम", + "comment_deleted": "टिप्पणी हटा दी गई", + "comment_options": "टिप्पणी विकल्प", + "comments_and_likes": "टिप्पणियाँ और पसंद", + "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_link_to_share_description": "लिंक वाले किसी भी व्यक्ति को चयनित फ़ोटो देखने दें", + "create_new_person": "नया व्यक्ति बनाएं", + "create_new_person_hint": "चयनित संपत्तियों को एक नए व्यक्ति को सौंपें", + "create_new_user": "नया उपयोगकर्ता बनाएं", + "create_user": "उपयोगकर्ता बनाइये", + "created": "बनाया", + "current_device": "वर्तमान उपकरण", + "custom_locale": "कस्टम लोकेल", + "custom_locale_description": "भाषा और क्षेत्र के आधार पर दिनांक और संख्याएँ प्रारूपित करें", + "dark": "डार्क", + "date_after": "इसके बाद की तारीख", + "date_and_time": "तिथि और समय", + "date_before": "पहले की तारीख", + "date_of_birth_saved": "जन्मतिथि सफलतापूर्वक सहेजी गई", + "date_range": "तिथि सीमा", + "day": "दिन", + "deduplicate_all": "सभी को डुप्लिकेट करें", + "default_locale": "डिफ़ॉल्ट स्थान", + "default_locale_description": "अपने ब्राउज़र स्थान के आधार पर दिनांक और संख्याएँ प्रारूपित करें", + "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": "मूल फ़ोटो प्रदर्शित करें", + "display_original_photos_setting_description": "किसी संपत्ति को देखते समय थंबनेल के बजाय मूल तस्वीर प्रदर्शित करना पसंद करें जब मूल संपत्ति वेब-संगत हो।", + "do_not_show_again": "इस संदेश को दुबारा मत दिखाना", + "done": "ठीक है", + "download": "डाउनलोड करें", + "download_settings": "डाउनलोड करना", + "download_settings_description": "संपत्ति डाउनलोड से संबंधित सेटिंग्स प्रबंधित करें", + "downloading": "डाउनलोड", + "drop_files_to_upload": "अपलोड करने के लिए फ़ाइलें कहीं भी छोड़ें", + "duplicates": "डुप्लिकेट", + "duplicates_description": "प्रत्येक समूह को यह इंगित करके हल करें कि कौन सा, यदि कोई है, डुप्लिकेट है", + "duration": "अवधि", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "संपादन करना", + "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": "", + "empty_album": "", + "empty_trash": "कूड़ेदान खाली करें", + "empty_trash_confirmation": "क्या आपको यकीन है कि आप कचरा खाली करना चाहते हैं? यह इमिच से स्थायी रूप से कचरा में सभी संपत्तियों को हटा देगा।\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_asset_favorite": "संपत्ति के लिए पसंदीदा नहीं बदला जा सकता", + "cant_get_faces": "चेहरे नहीं मिल सके", + "cant_get_number_of_comments": "टिप्पणियों की संख्या नहीं मिल सकी", + "cant_search_people": "लोगों को खोजा नहीं जा सकता", + "cant_search_places": "स्थान खोज नहीं सकते", + "error_adding_assets_to_album": "एल्बम में संपत्ति जोड़ने में त्रुटि", + "error_adding_users_to_album": "एल्बम में उपयोगकर्ताओं को जोड़ने में त्रुटि", + "error_deleting_shared_user": "साझा उपयोगकर्ता को हटाने में त्रुटि", + "error_hiding_buy_button": "खरीदें बटन छिपाने में त्रुटि", + "error_removing_assets_from_album": "एल्बम से संपत्तियों को हटाने में त्रुटि, अधिक विवरण के लिए कंसोल की जाँच करें", + "error_selecting_all_assets": "सभी परिसंपत्तियों का चयन करने में त्रुटि", + "exclusion_pattern_already_exists": "यह बहिष्करण पैटर्न पहले से मौजूद है।", + "failed_to_create_album": "एल्बम बनाने में विफल", + "failed_to_create_shared_link": "साझा लिंक बनाने में विफल", + "failed_to_edit_shared_link": "साझा लिंक संपादित करने में विफल", + "failed_to_get_people": "लोगों को पाने में विफल", + "failed_to_load_asset": "परिसंपत्ति लोड करने में विफल", + "failed_to_load_assets": "परिसंपत्तियाँ लोड करने में विफल", + "failed_to_load_people": "लोगों को लोड करने में विफल", + "failed_to_remove_product_key": "उत्पाद कुंजी निकालने में विफल", + "failed_to_stack_assets": "परिसंपत्तियों का ढेर लगाने में विफल", + "failed_to_unstack_assets": "परिसंपत्तियों का ढेर खोलने में विफल", + "import_path_already_exists": "यह आयात पथ पहले से मौजूद है।", + "incorrect_email_or_password": "गलत ईमेल या पासवर्ड", + "profile_picture_transparent_pixels": "प्रोफ़ाइल चित्रों में पारदर्शी पिक्सेल नहीं हो सकते।", + "quota_higher_than_disk_size": "आपने डिस्क आकार से अधिक कोटा निर्धारित किया है", + "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_change_album_user_role": "एल्बम उपयोगकर्ता की भूमिका बदलने में असमर्थ", + "unable_to_change_date": "दिनांक बदलने में असमर्थ", + "unable_to_change_favorite": "संपत्ति के लिए पसंदीदा बदलने में असमर्थ", + "unable_to_change_location": "स्थान बदलने में असमर्थ", + "unable_to_change_password": "पासवर्ड बदलने में असमर्थ", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth लॉगिन पूर्ण करने में असमर्थ", + "unable_to_connect": "कनेक्ट करने में असमर्थ", + "unable_to_connect_to_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_oauth_account": "OAuth खाता लिंक करने में असमर्थ", + "unable_to_load_album": "एल्बम लोड करने में असमर्थ", + "unable_to_load_asset_activity": "परिसंपत्ति गतिविधि लोड करने में असमर्थ", + "unable_to_load_items": "आइटम लोड करने में असमर्थ", + "unable_to_load_liked_status": "पसंद की गई स्थिति लोड करने में असमर्थ", + "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_new_person": "किसी नये व्यक्ति को संपत्ति पुनः सौंपने में असमर्थ", + "unable_to_refresh_user": "उपयोगकर्ता को ताज़ा करने में असमर्थ", + "unable_to_remove_album_users": "उपयोगकर्ताओं को एल्बम से निकालने में असमर्थ", + "unable_to_remove_api_key": "API कुंजी निकालने में असमर्थ", + "unable_to_remove_assets_from_shared_link": "साझा लिंक से संपत्तियों को निकालने में असमर्थ", + "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "ऑफ़लाइन फ़ाइलें निकालने में असमर्थ", + "unable_to_remove_library": "लाइब्रेरी हटाने में असमर्थ", + "unable_to_remove_partner": "पार्टनर को हटाने में असमर्थ", + "unable_to_remove_reaction": "प्रतिक्रिया निकालने में असमर्थ", + "unable_to_remove_user": "", + "unable_to_repair_items": "वस्तुओं की मरम्मत करने में असमर्थ", + "unable_to_reset_password": "पासवर्ड रीसेट करने में असमर्थ", + "unable_to_resolve_duplicate": "डुप्लिकेट का समाधान करने में असमर्थ", + "unable_to_restore_assets": "संपत्तियों को पुनर्स्थापित करने में असमर्थ", + "unable_to_restore_trash": "कचरा पुनर्स्थापित करने में असमर्थ", + "unable_to_restore_user": "उपयोगकर्ता को पुनर्स्थापित करने में असमर्थ", + "unable_to_save_album": "एल्बम सहेजने में असमर्थ", + "unable_to_save_api_key": "एपीआई कुंजी सहेजने में असमर्थ", + "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": "फ़ीचर फ़ोटो सेट करने में असमर्थ", + "unable_to_set_profile_picture": "प्रोफ़ाइल चित्र सेट करने में असमर्थ", + "unable_to_submit_job": "कार्य प्रस्तुत करने में असमर्थ", + "unable_to_trash_asset": "संपत्ति को ट्रैश करने में असमर्थ", + "unable_to_unlink_account": "खाता अनलिंक करने में असमर्थ", + "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_timeline_display_status": "समयरेखा प्रदर्शन स्थिति अद्यतन करने में असमर्थ", + "unable_to_update_user": "उपयोगकर्ता को अद्यतन करने में असमर्थ", + "unable_to_upload_file": "फाइल अपलोड करने में असमर्थ" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "एक्सिफ", + "exit_slideshow": "स्लाइड शो से बाहर निकलें", + "expand_all": "सभी का विस्तार", + "expire_after": "एक्सपायर आफ्टर", + "expired": "खत्म हो चुका", + "explore": "अन्वेषण करना", + "export": "निर्यात", + "export_as_json": "JSON के रूप में निर्यात करें", + "extension": "विस्तार", + "external": "बाहरी", + "external_libraries": "बाहरी पुस्तकालय", + "face_unassigned": "सौंपे नहीं गए", + "failed_to_get_people": "", + "favorite": "पसंदीदा", + "favorite_or_unfavorite_photo": "पसंदीदा या नापसंद फोटो", + "favorites": "पसंदीदा", + "feature": "", + "feature_photo_updated": "फ़ीचर फ़ोटो अपडेट किया गया", + "featurecollection": "", + "file_name": "फ़ाइल का नाम", + "file_name_or_extension": "फ़ाइल का नाम या एक्सटेंशन", + "filename": "फ़ाइल का नाम", + "files": "", + "filetype": "फाइल का प्रकार", + "filter_people": "लोगों को फ़िल्टर करें", + "find_them_fast": "खोज के साथ नाम से उन्हें तेजी से ढूंढें", + "fix_incorrect_match": "ग़लत मिलान ठीक करें", + "force_re-scan_library_files": "सभी लाइब्रेरी फ़ाइलों को बलपूर्वक पुनः स्कैन करें", + "forward": "आगे", + "general": "सामान्य", + "get_help": "मदद लें", + "getting_started": "शुरू करना", + "go_back": "वापस जाओ", + "go_to_search": "खोज पर जाएँ", + "go_to_share_page": "शेयर पेज पर जाएं", + "group_albums_by": "इनके द्वारा समूह एल्बम..।", + "group_no": "कोई समूहीकरण नहीं", + "group_owner": "स्वामी द्वारा समूह", + "group_year": "वर्ष के अनुसार समूह", + "has_quota": "कोटा है", + "hide_all_people": "सभी लोगों को छुपाएं", + "hide_gallery": "गैलरी छिपाएँ", + "hide_password": "पासवर्ड छिपाएं", + "hide_person": "व्यक्ति छिपाएँ", + "hide_unnamed_people": "अनाम लोगों को छुपाएं", + "host": "मेज़बान", + "hour": "घंटा", + "image": "छवि", + "img": "", + "immich_logo": "Immich लोगो", + "immich_web_interface": "इमिच वेब इंटरफ़ेस", + "import_from_json": "JSON से आयात करें", + "import_path": "आयात पथ", + "in_archive": "पुरालेख में", + "include_archived": "संग्रहीत शामिल करें", + "include_shared_albums": "साझा किए गए एल्बम शामिल करें", + "include_shared_partner_assets": "साझा भागीदार संपत्तियां शामिल करें", + "individual_share": "व्यक्तिगत हिस्सेदारी", + "info": "जानकारी", + "interval": { + "day_at_onepm": "हर दिन दोपहर 1 बजे", + "hours": "", + "night_at_midnight": "हर रात आधी रात को", + "night_at_twoam": "हर रात 2 बजे" + }, + "invite_people": "लोगो को निमंत्रण भेजो", + "invite_to_album": "एल्बम के लिए आमंत्रित करें", + "job_settings_description": "", + "jobs": "नौकरियां", + "keep": "रखना", + "keep_all": "सभी रखना", + "keyboard_shortcuts": "कुंजीपटल अल्प मार्ग", + "language": "भाषा", + "language_setting_description": "अपनी पसंदीदा भाषा चुनें", + "last_seen": "अंतिम बार देखा गया", + "latest_version": "नवीनतम संस्करण", + "latitude": "अक्षांश", + "leave": "छुट्टी", + "let_others_respond": "दूसरों को जवाब देने दें", + "level": "स्तर", + "library": "पुस्तकालय", + "library_options": "पुस्तकालय विकल्प", + "light": "रोशनी", + "like_deleted": "जैसे हटा दिया गया", + "link_options": "लिंक विकल्प", + "link_to_oauth": "OAuth से लिंक करें", + "linked_oauth_account": "लिंक किया गया OAuth खाता", + "list": "सूची", + "loading": "लोड हो रहा है", + "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": "विवरण व्यूअर में किसी वीडियो को स्वचालित रूप से लूप करने में सक्षम करें।", + "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": "अपना OAuth कनेक्शन प्रबंधित करें", + "map": "नक्शा", + "map_marker_with_image": "छवि के साथ मानचित्र मार्कर", + "map_settings": "मानचित्र सेटिंग", + "matches": "माचिस", + "media_type": "मीडिया प्रकार", + "memories": "यादें", + "memories_setting_description": "आप अपनी यादों में जो देखते हैं उसे प्रबंधित करें", + "memory": "याद", + "menu": "मेन्यू", + "merge": "मर्ज", + "merge_people": "लोगों को मिलाओ", + "merge_people_limit": "आप एक समय में अधिकतम 5 चेहरों को ही मर्ज कर सकते हैं", + "merge_people_prompt": "क्या आप इन लोगों का विलय करना चाहते हैं? यह कार्रवाई अपरिवर्तनीय है।", + "merge_people_successfully": "लोगों को सफलतापूर्वक मर्ज करें", + "minimize": "छोटा करना", + "minute": "मिनट", + "missing": "गुम", + "model": "मॉडल", + "month": "महीना", + "more": "अधिक", + "moved_to_trash": "कूड़ेदान में ले जाया गया", + "my_albums": "मेरे एल्बम", + "name": "नाम", + "name_or_nickname": "नाम या उपनाम", + "never": "कभी नहीं", + "new_album": "नयी एल्बम", + "new_api_key": "नई एपीआई कुंजी", + "new_password": "नया पासवर्ड", + "new_person": "नया व्यक्ति", + "new_user_created": "नया उपयोगकर्ता बनाया गया", + "new_version_available": "नया संस्करण उपलब्ध है", + "newest_first": "नवीनतम पहले", + "next": "अगला", + "next_memory": "अगली स्मृति", + "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": "कोई एक्सिफ़ जानकारी उपलब्ध नहीं है", + "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": "ईमेल सूचनाएं सक्षम करें", + "notifications": "सूचनाएं", + "notifications_setting_description": "सूचनाएं प्रबंधित करें", + "oauth": "OAuth", + "offline": "ऑफलाइन", + "offline_paths": "ऑफ़लाइन पथ", + "offline_paths_description": "ये परिणाम उन फ़ाइलों को मैन्युअल रूप से हटाने के कारण हो सकते हैं जो बाहरी लाइब्रेरी का हिस्सा नहीं हैं।", + "ok": "ठीक है", + "oldest_first": "सबसे पुराना पहले", + "onboarding": "ज्ञानप्राप्ति", + "onboarding_theme_description": "अपने उदाहरण के लिए एक रंग थीम चुनें।", + "onboarding_welcome_description": "आइए कुछ सामान्य सेटिंग्स के साथ अपना इंस्टेंस सेट अप करें।", + "online": "ऑनलाइन", + "only_favorites": "केवल पसंदीदा", + "only_refreshes_modified_files": "केवल संशोधित फ़ाइलों को ताज़ा करता है", + "open_in_openstreetmap": "OpenStreetMap में खोलें", + "open_the_search_filters": "खोज फ़िल्टर खोलें", + "options": "विकल्प", + "or": "या", + "organize_your_library": "अपनी लाइब्रेरी व्यवस्थित करें", + "original": "मूल", + "other": "अन्य", + "other_devices": "अन्य उपकरण", + "other_variables": "अन्य चर", + "owned": "स्वामित्व", + "owner": "मालिक", + "partner": "साथी", + "partner_can_access_assets": "संग्रहीत और हटाए गए को छोड़कर आपके सभी फ़ोटो और वीडियो", + "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": "लोग", + "people_sidebar_description": "साइडबार में लोगों के लिए एक लिंक प्रदर्शित करें", + "perform_library_tasks": "", + "permanent_deletion_warning": "स्थायी विलोपन चेतावनी", + "permanent_deletion_warning_setting_description": "संपत्तियों को स्थायी रूप से हटाते समय एक चेतावनी दिखाएं", + "permanently_delete": "स्थायी रूप से हटाना", + "permanently_deleted_asset": "स्थायी रूप से हटाई गई संपत्ति", + "person": "व्यक्ति", + "photo_shared_all_users": "ऐसा लगता है कि आपने अपनी तस्वीरें सभी उपयोगकर्ताओं के साथ साझा कीं या आपके पास साझा करने के लिए कोई उपयोगकर्ता नहीं है।", + "photos": "तस्वीरें", + "photos_and_videos": "तस्वीरें और वीडियो", + "photos_from_previous_years": "पिछले वर्षों की तस्वीरें", + "pick_a_location": "एक स्थान चुनें", + "place": "जगह", + "places": "स्थानों", + "play": "खेल", + "play_memories": "यादें खेलें", + "play_motion_photo": "मोशन फ़ोटो चलाएं", + "play_or_pause_video": "वीडियो चलाएं या रोकें", + "point": "", + "port": "पत्तन", + "preset": "प्रीसेट", + "preview": "पूर्व दर्शन", + "previous": "पहले का", + "previous_memory": "पिछली स्मृति", + "previous_or_next_photo": "पिछला या अगला फ़ोटो", + "primary": "प्राथमिक", + "profile_picture_set": "प्रोफ़ाइल चित्र सेट।", + "public_album": "सार्वजनिक एल्बम", + "public_share": "सार्वजनिक शेयर", + "purchase_account_info": "समर्थक", + "purchase_activated_subtitle": "इमिच और ओपन-सोर्स सॉफ़्टवेयर का समर्थन करने के लिए धन्यवाद", + "purchase_activated_title": "आपकी कुंजी सफलतापूर्वक सक्रिय कर दी गई है", + "purchase_button_activate": "सक्रिय", + "purchase_button_buy": "खरीदना", + "purchase_button_buy_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": "सेवा के निरंतर विकास का समर्थन करने के लिए इमिच खरीदें", + "purchase_lifetime_description": "जीवन भर की खरीदारी", + "purchase_option_title": "खरीद विकल्प", + "purchase_panel_info_1": "इमिच को बनाने में बहुत समय और प्रयास लगता है, और हमारे पास इसे जितना संभव हो सके उतना अच्छा बनाने के लिए पूर्णकालिक इंजीनियर इस पर काम कर रहे हैं।", + "purchase_panel_info_2": "चूंकि हम पेवॉल नहीं जोड़ने के लिए प्रतिबद्ध हैं, इसलिए यह खरीदारी आपको इमिच में कोई अतिरिक्त सुविधाएं नहीं देगी।", + "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": "सर्वर उत्पाद कुंजी व्यवस्थापक द्वारा प्रबंधित की जाती है", + "range": "", + "raw": "", + "reaction_options": "प्रतिक्रिया विकल्प", + "read_changelog": "चेंजलॉग पढ़ें", + "reassign": "पुनः असाइन", + "reassing_hint": "चयनित संपत्तियों को किसी मौजूदा व्यक्ति को सौंपें", + "recent": "हाल ही का", + "recent_searches": "हाल की खोजें", + "refresh": "ताज़ा करना", + "refresh_encoded_videos": "एन्कोडेड वीडियो ताज़ा करें", + "refresh_metadata": "मेटाडेटा ताज़ा करें", + "refresh_thumbnails": "थंबनेल ताज़ा करें", + "refreshed": "ताज़ा किया", + "refreshes_every_file": "प्रत्येक फ़ाइल को ताज़ा करता है", + "refreshing_encoded_video": "ताज़ा किया जा रहा एन्कोडेड वीडियो", + "refreshing_metadata": "ताज़ा मेटाडेटा", + "regenerating_thumbnails": "पुनर्जीवित थंबनेल", + "remove": "निकालना", + "remove_assets_title": "संपत्तियाँ हटाएँ?", + "remove_custom_date_range": "कस्टम दिनांक सीमा हटाएँ", + "remove_deleted_assets": "ऑफ़लाइन फ़ाइलें हटाएँ", + "remove_from_album": "एल्बम से हटाएँ", + "remove_from_favorites": "पसंदीदा से निकालें", + "remove_from_shared_link": "साझा लिंक से हटाएँ", + "remove_user": "उपयोगकर्ता को हटाएँ", + "removed_from_archive": "संग्रह से हटा दिया गया", + "removed_from_favorites": "पसंदीदा से हटाया गया", + "rename": "नाम बदलें", + "repair": "मरम्मत", + "repair_no_results_message": "ट्रैक न की गई और गुम फ़ाइलें यहां दिखाई देंगी", + "replace_with_upload": "अपलोड के साथ बदलें", + "repository": "कोष", + "require_password": "पासवर्ड की आवश्यकता है", + "require_user_to_change_password_on_first_login": "उपयोगकर्ता को पहले लॉगिन पर पासवर्ड बदलने की आवश्यकता है", + "reset": "रीसेट", + "reset_password": "पासवर्ड रीसेट", + "reset_people_visibility": "लोगों की दृश्यता रीसेट करें", + "reset_settings_to_default": "", + "reset_to_default": "वितथ पर ले जाएं", + "resolve_duplicates": "डुप्लिकेट का समाधान करें", + "resolved_all_duplicates": "सभी डुप्लिकेट का समाधान किया गया", + "restore": "पुनर्स्थापित करना", + "restore_all": "सभी बहाल करो", + "restore_user": "उपयोगकर्ता को पुनर्स्थापित करें", + "restored_asset": "पुनर्स्थापित संपत्ति", + "resume": "फिर शुरू करना", + "retry_upload": "पुनः अपलोड करने का प्रयास करें", + "review_duplicates": "डुप्लिकेट की समीक्षा करें", + "role": "भूमिका", + "role_editor": "संपादक", + "role_viewer": "दर्शक", + "save": "बचाना", + "saved_api_key": "सहेजी गई एपीआई कुंजी", + "saved_profile": "प्रोफ़ाइल सहेजी गई", + "saved_settings": "सहेजी गई सेटिंग्स", + "say_something": "कुछ कहें", + "scan_all_libraries": "सभी पुस्तकालयों को स्कैन करें", + "scan_all_library_files": "सभी लाइब्रेरी फ़ाइलों को पुनः स्कैन करें", + "scan_new_library_files": "नई लाइब्रेरी फ़ाइलें स्कैन करें", + "scan_settings": "सेटिंग्स स्कैन करें", + "scanning_for_album": "एल्बम के लिए स्कैन किया जा रहा है..।", + "search": "खोज", + "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_existing_person": "मौजूदा व्यक्ति को खोजें", + "search_no_people": "कोई लोग नहीं", + "search_people": "लोगों को खोजें", + "search_places": "स्थान खोजें", + "search_state": "स्थिति खोजें..।", + "search_timezone": "समयक्षेत्र खोजें..।", + "search_type": "तलाश की विधि", + "search_your_photos": "अपनी फ़ोटो खोजें", + "searching_locales": "स्थान खोजे जा रहे हैं..।", + "second": "दूसरा", + "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_photos": "फ़ोटो चुनें", + "select_trash_all": "ट्रैश ऑल का चयन करें", + "selected": "चयनित", + "send_message": "मेसेज भेजें", + "send_welcome_email": "स्वागत ईमेल भेजें", + "server": "", + "server_offline": "सर्वर ऑफ़लाइन", + "server_online": "सर्वर ऑनलाइन", + "server_stats": "सर्वर आँकड़े", + "server_version": "सर्वर संस्करण", + "set": "तय करना", + "set_as_album_cover": "एल्बम कवर के रूप में सेट करें", + "set_as_profile_picture": "प्रोफाइल चित्र के रूप में सेट", + "set_date_of_birth": "जन्मतिथि निर्धारित करें", + "set_profile_picture": "प्रोफ़ाइल चित्र सेट करें", + "set_slideshow_to_fullscreen": "स्लाइड शो को फ़ुलस्क्रीन पर सेट करें", + "settings": "समायोजन", + "settings_saved": "सेटिंग्स को सहेजा गया", + "share": "शेयर करना", + "shared": "साझा", + "shared_by": "द्वारा साझा", + "shared_by_you": "आपके द्वारा साझा किया गया", + "shared_links": "साझा किए गए लिंक", + "sharing": "शेयरिंग", + "sharing_enter_password": "कृपया इस पृष्ठ को देखने के लिए पासवर्ड दर्ज करें।", + "sharing_sidebar_description": "साइडबार में शेयरिंग के लिए एक लिंक प्रदर्शित करें", + "shift_to_permanent_delete": "संपत्ति को स्थायी रूप से हटाने के लिए ⇧ दबाएँ", + "show_album_options": "एल्बम विकल्प दिखाएँ", + "show_all_people": "सभी लोगों को दिखाओ", + "show_and_hide_people": "लोगों को दिखाएँ और छिपाएँ", + "show_file_location": "फ़ाइल स्थान दिखाएँ", + "show_gallery": "गैलरी दिखाएँ", + "show_hidden_people": "छुपे हुए लोगों को दिखाएं", + "show_in_timeline": "टाइमलाइन में दिखाएँ", + "show_in_timeline_setting_description": "अपनी टाइमलाइन में इस उपयोगकर्ता के फ़ोटो और वीडियो दिखाएं", + "show_keyboard_shortcuts": "कुंजीपटल शॉर्टकट दिखाएँ", + "show_metadata": "मेटाडेटा दिखाएं", + "show_or_hide_info": "जानकारी दिखाएँ या छिपाएँ", + "show_password": "पासवर्ड दिखाए", + "show_person_options": "व्यक्ति विकल्प दिखाएँ", + "show_progress_bar": "प्रगति पट्टी दिखाएँ", + "show_search_options": "खोज विकल्प दिखाएँ", + "show_supporter_badge": "समर्थक बिल्ला", + "show_supporter_badge_description": "समर्थक बैज दिखाएँ", + "shuffle": "मिश्रण", + "sign_out": "साइन आउट", + "sign_up": "साइन अप करें", + "size": "आकार", + "skip_to_content": "इसे छोड़कर सामग्री पर बढ़ने के लिए", + "slideshow": "स्लाइड शो", + "slideshow_settings": "स्लाइड शो सेटिंग्स", + "sort_albums_by": "एल्बम को क्रमबद्ध करें..।", + "sort_created": "बनाया गया दिनांक", + "sort_items": "मदों की संख्या", + "sort_modified": "डेटा संशोधित", + "sort_oldest": "सबसे पुरानी तस्वीर", + "sort_recent": "सबसे ताज़ा फ़ोटो", + "sort_title": "शीर्षक", + "source": "स्रोत", + "stack": "ढेर", + "stack_selected_photos": "चयनित फ़ोटो को ढेर करें", + "stacktrace": "स्टैक ट्रेस", + "start": "शुरू", + "start_date": "आरंभ करने की तिथि", + "state": "राज्य", + "status": "स्थिति", + "stop_motion_photo": "स्टॉप मोशन फोटो", + "stop_photo_sharing": "अपनी तस्वीरें साझा करना बंद करें?", + "stop_sharing_photos_with_user": "इस उपयोगकर्ता के साथ अपनी तस्वीरें साझा करना बंद करें", + "storage": "स्टोरेज की जगह", + "storage_label": "भंडारण लेबल", + "submit": "जमा करना", + "suggestions": "सुझाव", + "sunrise_on_the_beach": "समुद्र तट पर सूर्योदय", + "swap_merge_direction": "मर्ज दिशा स्वैप करें", + "sync": "साथ-साथ करना", + "template": "खाका", + "theme": "विषय", + "theme_selection": "थीम चयन", + "theme_selection_description": "आपके ब्राउज़र की सिस्टम प्राथमिकता के आधार पर थीम को स्वचालित रूप से प्रकाश या अंधेरे पर सेट करें", + "they_will_be_merged_together": "इन्हें एक साथ मिला दिया जाएगा", + "time_based_memories": "समय आधारित यादें", + "timezone": "समय क्षेत्र", + "to_archive": "पुरालेख", + "to_change_password": "पासवर्ड बदलें", + "to_favorite": "पसंदीदा", + "to_login": "लॉग इन करें", + "to_trash": "कचरा", + "toggle_settings": "सेटिंग्स टॉगल करें", + "toggle_theme": "थीम टॉगल करें", + "toggle_visibility": "", + "total_usage": "कुल उपयोग", + "trash": "कचरा", + "trash_all": "सब कचरा", + "trash_delete_asset": "संपत्ति को ट्रैश/डिलीट करें", + "trash_no_results_message": "ट्रैश की गई फ़ोटो और वीडियो यहां दिखाई देंगे।", + "type": "प्रकार", + "unarchive": "संग्रह से निकालें", + "unarchived": "", + "unfavorite": "नापसंद करें", + "unhide_person": "व्यक्ति को उजागर करें", + "unknown": "अज्ञात", + "unknown_album": "", + "unknown_year": "अज्ञात वर्ष", + "unlimited": "असीमित", + "unlink_oauth": "OAuth को अनलिंक करें", + "unlinked_oauth_account": "OAuth खाता अनलिंक किया गया", + "unnamed_album": "अनाम एल्बम", + "unnamed_share": "अनाम साझा करें", + "unsaved_change": "सहेजा न गया परिवर्तन", + "unselect_all": "सभी को अचयनित करें", + "unselect_all_duplicates": "सभी डुप्लिकेट को अचयनित करें", + "unstack": "स्टैक रद्द करें", + "untracked_files": "ट्रैक न की गई फ़ाइलें", + "untracked_files_decription": "इन फ़ाइलों को एप्लिकेशन द्वारा ट्रैक नहीं किया जाता है. वे असफल चालों, बाधित अपलोड या किसी बग के कारण पीछे छूट जाने का परिणाम हो सकते हैं", + "up_next": "अब अगला", + "updated_password": "अद्यतन पासवर्ड", + "upload": "डालना", + "upload_concurrency": "समवर्ती अपलोड करें", + "upload_status_duplicates": "डुप्लिकेट", + "upload_status_errors": "त्रुटियाँ", + "upload_status_uploaded": "अपलोड किए गए", + "upload_success": "अपलोड सफल रहा, नई अपलोड संपत्तियां देखने के लिए पेज को रीफ्रेश करें।", + "url": "यूआरएल", + "usage": "प्रयोग", + "use_custom_date_range": "इसके बजाय कस्टम दिनांक सीमा का उपयोग करें", + "user": "उपयोगकर्ता", + "user_id": "उपयोगकर्ता पहचान", + "user_purchase_settings": "खरीदना", + "user_purchase_settings_description": "अपनी खरीदारी प्रबंधित करें", + "user_usage_detail": "उपयोगकर्ता उपयोग विवरण", + "username": "उपयोगकर्ता नाम", + "users": "उपयोगकर्ताओं", + "utilities": "उपयोगिताओं", + "validate": "मान्य", + "variables": "चर", + "version": "संस्करण", + "version_announcement_closing": "आपका मित्र, एलेक्स", + "version_announcement_message": "नमस्कार मित्र, एप्लिकेशन का एक नया संस्करण है, कृपया अपना समय निकालकर इसे देखें रिलीज नोट्स और अपना सुनिश्चित करें docker-compose.yml, और .env किसी भी गलत कॉन्फ़िगरेशन को रोकने के लिए सेटअप अद्यतित है, खासकर यदि आप वॉचटावर या किसी भी तंत्र का उपयोग करते हैं जो आपके एप्लिकेशन को स्वचालित रूप से अपडेट करने का प्रबंधन करता है।", + "video": "वीडियो", + "video_hover_setting": "होवर पर वीडियो थंबनेल चलाएं", + "video_hover_setting_description": "जब माउस आइटम पर घूम रहा हो तो वीडियो थंबनेल चलाएं।", + "videos": "वीडियो", + "view": "देखना", + "view_album": "एल्बम देखें", + "view_all": "सभी को देखें", + "view_all_users": "सभी उपयोगकर्ताओं को देखें", + "view_links": "लिंक देखें", + "view_next_asset": "अगली संपत्ति देखें", + "view_previous_asset": "पिछली संपत्ति देखें", + "view_stack": "ढेर देखें", + "viewer": "", + "waiting": "इंतज़ार में", + "warning": "चेतावनी", + "week": "सप्ताह", + "welcome": "स्वागत", + "welcome_to_immich": "इमिच में आपका स्वागत है", + "year": "वर्ष", + "yes": "हाँ", + "you_dont_have_any_shared_links": "आपके पास कोई साझा लिंक नहीं है", + "zoom_image": "छवि ज़ूम करें" +} diff --git a/i18n/hr.json b/i18n/hr.json new file mode 100644 index 0000000000..0b1c6c64ba --- /dev/null +++ b/i18n/hr.json @@ -0,0 +1,1251 @@ +{ + "about": "O", + "account": "Račun", + "account_settings": "Postavke računa", + "acknowledge": "Potvrdi", + "action": "Akcija", + "actions": "Akcije", + "active": "Aktivno", + "activity": "Aktivnost", + "activity_changed": "Aktivnost je {enabled, select, true {omogućena} other {onemogućena}}", + "add": "Dodaj", + "add_a_description": "Dodaj opis", + "add_a_location": "Dodaj lokaciju", + "add_a_name": "Dodaj ime", + "add_a_title": "Dodaj naslov", + "add_exclusion_pattern": "Dodaj uzorak izuzimanja", + "add_import_path": "Dodaj import folder", + "add_location": "Dodaj lokaciju", + "add_more_users": "Dodaj još korisnika", + "add_partner": "Dodaj partnera", + "add_path": "Dodaj putanju", + "add_photos": "Dodaj slike", + "add_to": "Dodaj u...", + "add_to_album": "Dodaj u album", + "add_to_shared_album": "Dodaj u dijeljeni album", + "added_to_archive": "Dodano u arhivu", + "added_to_favorites": "Dodano u omiljeno", + "added_to_favorites_count": "Dodano {count, number} u omiljeno", + "admin": { + "add_exclusion_pattern_description": "Dodajte uzorke izuzimanja. Globiranje pomoću *, ** i ? je podržano. Za ignoriranje svih datoteka u bilo kojem direktoriju pod nazivom \"Raw\", koristite \"**/Raw/**\". Da biste zanemarili sve datoteke koje završavaju na \".tif\", koristite \"**/*.tif\". Da biste zanemarili apsolutni put, koristite \"/path/to/ignore/**\".", + "asset_offline_description": "Ovo sredstvo vanjske knjižnice više nije pronađeno na disku i premješteno je u smeće. Ako je datoteka premještena unutar biblioteke, provjerite svoju vremensku traku za novo odgovarajuće sredstvo. Da biste vratili ovo sredstvo, provjerite može li Immich pristupiti donjoj stazi datoteke i skenirajte biblioteku.", + "authentication_settings": "Postavke autentikacije", + "authentication_settings_description": "Uredi lozinku, OAuth, i druge postavke autentikacije", + "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", + "authentication_settings_reenable": "Za ponovno uključivanje upotrijebite naredbu poslužitelja.", + "background_task_job": "Pozadinski zadaci", + "check_all": "Provjeri sve", + "cleared_jobs": "Izbrisani poslovi za: {job}", + "config_set_by_file": "Konfiguracija je trenutno postavljena konfiguracijskom datotekom", + "confirm_delete_library": "Jeste li sigurni da želite izbrisati biblioteku {library}?", + "confirm_delete_library_assets": "Jeste li sigurni da želite izbrisati ovu biblioteku? Time će se izbrisati sva {count} sadržana sredstva iz Immicha i ne može se poništiti. Datoteke će ostati na disku.", + "confirm_email_below": "Za potvrdu upišite \"{email}\" ispod", + "confirm_reprocess_all_faces": "Jeste li sigurni da želite ponovno obraditi sva lica? Ovo će također obrisati imenovane osobe.", + "confirm_user_password_reset": "Jeste li sigurni da želite poništiti lozinku korisnika {user}?", + "create_job": "Izradi zadatak", + "crontab_guru": "Crontab Guru", + "disable_login": "Onemogući prijavu", + "duplicate_detection_job_description": "Pokrenite strojno učenje na materijalima kako biste otkrili slične slike. Oslanja se na Pametno Pretraživanje", + "exclusion_pattern_description": "Uzorci izuzimanja omogućuju vam da zanemarite datoteke i mape prilikom skeniranja svoje biblioteke. Ovo je korisno ako imate mape koje sadrže datoteke koje ne želite uvesti, kao što su RAW datoteke.", + "external_library_created_at": "Vanjska biblioteka (stvorena: {date})", + "external_library_management": "Upravljanje vanjskom knjižnicom", + "face_detection": "Detekcija lica", + "face_detection_description": "Prepoznajte lica u sredstvima pomoću strojnog učenja. Za videozapise u obzir se uzima samo minijaturni prikaz. \"Sve\" (ponovno) obrađuje svu imovinu. \"Nedostaje\" stavlja u red čekanja sredstva koja još nisu obrađena. Otkrivena lica bit će stavljena u red čekanja za prepoznavanje lica nakon dovršetka prepoznavanja lica, grupirajući ih u postojeće ili nove osobe.", + "facial_recognition_job_description": "Grupirajte otkrivena lica u osobe. Ovaj se korak pokreće nakon dovršetka prepoznavanja lica. \"Sve\" (ponovno) grupira sva lica. \"Nedostajuća\" lica u redovima kojima nije dodijeljena osoba.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", + "force_delete_user_warning": "UPOZORENJE: Ovo će odmah ukloniti korisnika i sve pripadajuće podatke. Ovo se ne može poništiti i datoteke se ne mogu vratiti.", + "forcing_refresh_library_files": "Prisilno osvježavanje svih datoteka knjižnice", + "image_format": "Format", + "image_format_description": "WebP proizvodi manje datoteke od JPEG-a, ali se sporije kodira.", + "image_prefer_embedded_preview": "Preferiraj ugrađeni pregled", + "image_prefer_embedded_preview_setting_description": "Koristite ugrađene preglede u RAW fotografije kao ulaz za obradu slike kada su dostupni. To može proizvesti preciznije boje za neke slike, ali kvaliteta pregleda ovisi o kameri i slika može imati više artefakata kompresije.", + "image_prefer_wide_gamut": "Preferirajte široku gamu", + "image_prefer_wide_gamut_setting_description": "Koristite Display P3 za sličice. Ovo bolje čuva živost slika sa širokim prostorima boja, ali slike mogu izgledati drugačije na starim uređajima sa starom verzijom preglednika. sRGB slike čuvaju se kao sRGB kako bi se izbjegle promjene boja.", + "image_preview_description": "Slika srednje veličine s ogoljenim metapodacima, koristi se prilikom pregledavanja jednog sredstva i za strojno učenje", + "image_preview_format": "Format pregleda", + "image_preview_quality_description": "Kvaliteta pregleda od 1-100. Više je bolje, ali proizvodi veće datoteke i može smanjiti odziv aplikacije. Postavljanje niske vrijednosti može utjecati na kvalitetu strojnog učenja.", + "image_preview_resolution": "Razlučivost pregleda", + "image_preview_resolution_description": "Koristi se pri gledanju jedne fotografije i za strojno učenje. Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", + "image_quality": "Kvaliteta", + "image_quality_description": "Kvaliteta slike od 1-100. Više je bolji za kvalitetu, ali daje veće datoteke, ova opcija utječe na Pretpregled i sličice.", + "image_settings": "Postavke slike", + "image_settings_description": "Upravljajte kvalitetom i rezolucijom generiranih slika", + "image_thumbnail_format": "Format sličica", + "image_thumbnail_resolution": "Razlučivost sličica", + "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", + "job_concurrency": "{job} istovremenost", + "job_created": "Zadatak je kreiran", + "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", + "job_settings": "Postavke posla", + "job_settings_description": "Upravljajte istovremenošću poslova", + "job_status": "Status posla", + "jobs_delayed": "{jobCount, plural, other {# delayed}}", + "jobs_failed": "{jobCount, plural, other {# failed}}", + "library_created": "Stvorena biblioteka: {library}", + "library_cron_expression": "Cron izraz", + "library_cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", + "library_cron_expression_presets": "Unaprijed postavljene cron izraze", + "library_deleted": "Biblioteka izbrisana", + "library_import_path_description": "Navedite mapu za uvoz. Ova će se mapa, uključujući podmape, skenirati u potrazi za slikama i videozapisima.", + "library_scanning": "Periodično Skeniranje", + "library_scanning_description": "Konfigurirajte periodično skeniranje biblioteke", + "library_scanning_enable_description": "Omogući periodično skeniranje biblioteke", + "library_settings": "Externa biblioteka", + "library_settings_description": "Upravljajte postavkama vanjske biblioteke", + "library_tasks_description": "Obavljati bibliotekne zadatke", + "library_watching_enable_description": "Pratite vanjske biblioteke za promjena datoteke", + "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", + "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", + "logging_enable_description": "Omogući zapisivanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisivanja koristiti.", + "logging_settings": "Zapisivanje", + "machine_learning_clip_model": "CLIP model", + "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", + "machine_learning_duplicate_detection": "Detekcija Duplikata", + "machine_learning_duplicate_detection_enabled": "Omogući detekciju duplikata", + "machine_learning_duplicate_detection_enabled_description": "Ako je onemogućeno, potpuno identična sredstva i dalje će biti deduplicirana.", + "machine_learning_duplicate_detection_setting_description": "Upotrijebite CLIP ugradnje da biste pronašli vjerojatne duplikate", + "machine_learning_enabled": "Uključi strojsko učenje", + "machine_learning_enabled_description": "Ukoliko je ovo isključeno, sve funkcije strojnoga učenja biti će isključene bez obzira na postavke ispod.", + "machine_learning_facial_recognition": "Detekcija lica", + "machine_learning_facial_recognition_description": "Detektiraj, prepoznaj i grupiraj lica u fotografijama", + "machine_learning_facial_recognition_model": "Model prepoznavanja lica", + "machine_learning_facial_recognition_model_description": "Modeli su navedeni silaznim redoslijedom veličine. Veći modeli su sporiji i koriste više memorije, ali daju bolje rezultate. Imajte na umu da morate ponovno pokrenuti posao detekcije lica za sve slike nakon promjene modela.", + "machine_learning_facial_recognition_setting": "Omogući prepoznavanje lica", + "machine_learning_facial_recognition_setting_description": "Ako je onemogućeno, slike neće biti kodirane za prepoznavanje lica i neće popuniti odjeljak Ljudi na stranici Istraživanje.", + "machine_learning_max_detection_distance": "Maksimalna udaljenost za detektiranje", + "machine_learning_max_detection_distance_description": "Maksimalna udaljenost između dvije slike da bi se smatrale duplikatima, u rasponu od 0,001-0,1. Više vrijednosti otkrit će više duplikata, ali mogu rezultirati netočnim rezultatima.", + "machine_learning_max_recognition_distance": "Maksimalna udaljenost za detekciju", + "machine_learning_max_recognition_distance_description": "Maksimalna udaljenost između dva lica koja se smatraju istom osobom, u rasponu od 0-2. Snižavanje može spriječiti označavanje dvije osobe kao iste osobe, dok podizanje može spriječiti označavanje iste osobe kao dvije različite osobe. Imajte na umu da je lakše spojiti dvije osobe nego jednu osobu podijeliti na dvije, stoga koristite niži prag kada je to moguće.", + "machine_learning_min_detection_score": "Minimalni rezultat otkrivanja", + "machine_learning_min_detection_score_description": "Minimalni rezultat pouzdanosti za detektirano lice od 0-1. Niže vrijednosti otkrit će više lica, ali mogu dovesti do lažno pozitivnih rezultata.", + "machine_learning_min_recognized_faces": "Minimum prepoznatih lica", + "machine_learning_min_recognized_faces_description": "Najmanji broj prepoznatih lica za osobu koja se stvara. Povećanje toga čini prepoznavanje lica preciznijim po cijenu povećanja šanse da lice nije dodijeljeno osobi.", + "machine_learning_settings": "Postavke strojnog učenja", + "machine_learning_settings_description": "Upravljajte značajkama i postavkama strojnog učenja", + "machine_learning_smart_search": "Pametna pretraga", + "machine_learning_smart_search_description": "Pretražujte slike semantički koristeći CLIP ugradnje", + "machine_learning_smart_search_enabled": "Omogući pametno pretraživanje", + "machine_learning_smart_search_enabled_description": "Ako je onemogućeno, slike neće biti kodirane za pametno pretraživanje.", + "machine_learning_url_description": "URL poslužitelja strojnog učenja", + "manage_concurrency": "Upravljanje Istovremenošću", + "manage_log_settings": "Upravljanje postavkama zapisivanje", + "map_dark_style": "Tamni stil", + "map_enable_description": "Omogući značajke karte", + "map_gps_settings": "Postavke Karte i GPS-a", + "map_gps_settings_description": "Upravljajte Postavkama Karte i GPS-a (Obrnuto Geokodiranje)", + "map_implications": "Značajka karte se oslanja na vanjsku uslugu pločica (tiles.immich.cloud)", + "map_light_style": "Svijetli stil", + "map_manage_reverse_geocoding_settings": "Upravljajte postavkama Obrnutog Geokodiranja", + "map_reverse_geocoding": "Obrnuto Geokodiranje", + "map_reverse_geocoding_enable_description": "Omogući obrnuto geokodiranje", + "map_reverse_geocoding_settings": "Postavke Obrnuto Geokodiranje", + "map_settings": "Karta", + "map_settings_description": "Upravljanje postavkama karte", + "map_style_description": "URL na style.json temu karte", + "metadata_extraction_job": "Izdvoj metapodatke", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS, lica i rezolucija", + "metadata_faces_import_setting": "Omogući uvoz lica", + "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", + "metadata_settings": "Postavke Metapodataka", + "metadata_settings_description": "Upravljanje postavkama metapodataka", + "migration_job": "Migracija", + "migration_job_description": "Premjestite minijature za sredstva i lica u najnoviju strukturu mapa", + "no_paths_added": "Nema dodanih putanja", + "no_pattern_added": "Nije dodan uzorak", + "note_apply_storage_label_previous_assets": "Napomena: da biste primijenili Oznaku Pohrane na prethodno prenesena sredstva, pokrenite", + "note_cannot_be_changed_later": "NAPOMENA: Ovo se ne može promijeniti kasnije!", + "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", + "notification_email_from_address": "Od adrese", + "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", + "notification_email_host_description": "Poslužitelja e-pošte (npr. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignoriraj pogreške certifikata", + "notification_email_ignore_certificate_errors_description": "Ignoriraj pogreške provjere valjanosti TLS certifikata (nije preporučeno)", + "notification_email_password_description": "Lozinka za korištenje pri autentifikaciji s poslužiteljem e-pošte", + "notification_email_port_description": "Port poslužitelja e-pošte (npr. 25, 465, ili 587)", + "notification_email_sent_test_email_button": "Pošaljite probni e-mail i spremi", + "notification_email_setting_description": "Postavke za slanje e-mail obavijeste", + "notification_email_test_email": "Pošalji probni e-mail", + "notification_email_test_email_failed": "Slanje testne e-pošte nije uspjelo, provjerite svoje postavke", + "notification_email_test_email_sent": "Testna e-poruka poslana je na {email}. Provjerite svoju pristiglu poštu.", + "notification_email_username_description": "Korisničko ime koje se koristi pri autentifikaciji s poslužiteljem e-pošte", + "notification_enable_email_notifications": "Omogući obavijesti putem e-pošte", + "notification_settings": "Postavke Obavijesti", + "notification_settings_description": "Upravljanje postavkama obavijesti, uključujući e-poštu", + "oauth_auto_launch": "Automatsko pokretanje", + "oauth_auto_launch_description": "Automatski pokrenite OAuth prijavu nakon navigacije na stranicu za prijavu", + "oauth_auto_register": "Automatska registracija", + "oauth_auto_register_description": "Automatski registrirajte nove korisnike nakon prijave s OAuth", + "oauth_button_text": "Tekst gumba", + "oauth_client_id": "ID Klijenta", + "oauth_client_secret": "Tajna Klijenta", + "oauth_enable_description": "Prijavite se putem OAutha", + "oauth_issuer_url": "URL Izdavatelja", + "oauth_mobile_redirect_uri": "Mobilnog Preusmjeravanja URI", + "oauth_mobile_redirect_uri_override": "Nadjačavanje URI-preusmjeravanja za mobilne uređaje", + "oauth_mobile_redirect_uri_override_description": "Omogući kada pružatelj OAuth ne dopušta mobilni URI, poput '{callback}'", + "oauth_profile_signing_algorithm": "Algoritam za potpisivanje profila", + "oauth_profile_signing_algorithm_description": "Algoritam koji se koristi za potpisivanje korisničkog profila.", + "oauth_scope": "Opseg", + "oauth_settings": "OAuth", + "oauth_settings_description": "Upravljanje postavkama za prijavu kroz OAuth", + "oauth_settings_more_details": "Za više pojedinosti o ovoj značajci pogledajte uputstva.", + "oauth_signing_algorithm": "Algoritam potpisivanja", + "oauth_storage_label_claim": "Potraživanje oznake za pohranu", + "oauth_storage_label_claim_description": "Automatski postavite korisničku oznaku za pohranu na vrijednost ovog zahtjeva.", + "oauth_storage_quota_claim": "Zahtjev za kvotom pohrane", + "oauth_storage_quota_claim_description": "Automatski postavite korisničku kvotu pohrane na vrijednost ovog zahtjeva.", + "oauth_storage_quota_default": "Zadana kvota pohrane (GiB)", + "oauth_storage_quota_default_description": "Kvota u GiB koja će se koristiti kada nema zahtjeva (unesite 0 za neograničenu kvotu).", + "offline_paths": "Izvanmrežne putanje", + "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", + "password_enable_description": "Prijava s email adresom i zaporkom", + "password_settings": "Prijava zaporkom", + "password_settings_description": "Upravljanje postavkama za prijavu zaporkom", + "paths_validated_successfully": "Sve su putanje uspješno potvrđene", + "person_cleanup_job": "Čišćenje lica", + "quota_size_gib": "Veličina kvote (GiB)", + "refreshing_all_libraries": "Osvježavanje svih biblioteka", + "registration": "Registracija administratora", + "registration_description": "Budući da ste prvi korisnik na sustavu, bit ćete dodijeljeni administratorsku ulogu i odgovorni ste za administrativne poslove, a dodatne korisnike kreirat ćete sami.", + "removing_deleted_files": "Uklanjanje izvanmrežnih datoteka", + "repair_all": "Popravi sve", + "repair_matched_items": "Podudaranje {count, plural, one {# item} other {# items}}", + "repaired_items": "Popravljeno {count, plural, one {# item} other {# items}}", + "require_password_change_on_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", + "reset_settings_to_default": "Vrati postavke na zadane", + "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", + "scanning_library_for_changed_files": "Skeniranje biblioteke za promijenjene datoteke", + "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži zadatke…", + "send_welcome_email": "Pošaljite email dobrodošlice", + "server_external_domain_settings": "Vanjska domena", + "server_external_domain_settings_description": "Domena za javno dijeljene linkove, uključujući http(s)://", + "server_settings": "Postavke servera", + "server_settings_description": "Upravljanje postavkama servera", + "server_welcome_message": "Poruka dobrodošlice", + "server_welcome_message_description": "Poruka koja je prikazana na prijavi.", + "sidecar_job": "Sidecar metapodaci", + "sidecar_job_description": "Otkrijte ili sinkronizirajte sidecar metapodatke iz datotečnog sustava", + "slideshow_duration_description": "Broj sekundi za prikaz svake slike", + "smart_search_job_description": "Pokrenite strojno učenje na sredstvima za podršku pametnog pretraživanja", + "storage_template_date_time_description": "Vremenska oznaka stvaranja sredstva koristi se za informacije o datumu i vremenu", + "storage_template_date_time_sample": "Vrijeme uzorka {date}", + "storage_template_enable_description": "Omogući mehanizam predloška za pohranu", + "storage_template_hash_verification_enabled": "Omogućena hash provjera", + "storage_template_hash_verification_enabled_description": "Omogućuje hash provjeru, nemojte je onemogućiti osim ako niste sigurni u implikacije", + "storage_template_migration": "Migracija predloška za pohranu", + "storage_template_migration_description": "Primijenite trenutni {template} na prethodno prenesena sredstva", + "storage_template_migration_info": "Promjene predloška primjenjivat će se samo na nova sredstva. Za retroaktivnu primjenu predloška na prethodno prenesena sredstva, pokrenite {job}.", + "storage_template_migration_job": "Posao Migracije Predloška Pohrane", + "storage_template_more_details": "Za više pojedinosti o ovoj značajci pogledajte Predložak pohrane i njegove implikacije", + "storage_template_onboarding_description": "Kada je omogućena, ova će značajka automatski organizirati datoteke na temelju korisnički definiranog predloška. Zbog problema sa stabilnošću značajka je isključena prema zadanim postavkama. Za više informacija pogledajte dokumentaciju.", + "storage_template_path_length": "Približno ograničenje duljine putanje: {length, number}/{limit, number}", + "storage_template_settings": "Predložak pohrane", + "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", + "storage_template_user_label": "{label} je korisnička oznaka za pohranu", + "system_settings": "Postavke Sustava", + "tag_cleanup_job": "Čišćenje oznaka", + "theme_custom_css_settings": "Prilagođeni CSS", + "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", + "theme_settings": "Postavke tema", + "theme_settings_description": "Upravljajte prilagodbom Immich web sučelja", + "these_files_matched_by_checksum": "Ove datoteke se podudaraju prema njihovim kontrolnim zbrojevima", + "thumbnail_generation_job": "Generirajte sličice", + "thumbnail_generation_job_description": "Generirajte velike, male i zamućene sličice za svaki materijal, kao i sličice za svaku osobu", + "transcoding_acceleration_api": "API ubrzanja", + "transcoding_acceleration_api_description": "API koji će komunicirati s vašim uređajem radi ubrzanja transkodiranja. Ova postavka je 'najveći trud': vratit će se na softversko transkodiranje u slučaju kvara. VP9 može ili ne mora raditi ovisno o vašem hardveru.", + "transcoding_acceleration_nvenc": "NVENC (zahtjeva NVIDIA GPU)", + "transcoding_acceleration_qsv": "Quick Sync (zahtjeva Intel CPU sedme ili veće generacije)", + "transcoding_acceleration_rkmpp": "RKMPP (samo na Rockchip SOCima)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Prihvačeni audio kodeci", + "transcoding_accepted_audio_codecs_description": "Odaberite koji audio kodeci ne trebaju biti transkodirani. Samo korišteno za neka pravila za transkodiranje.", + "transcoding_accepted_containers": "Prihvaćeni kontenjeri", + "transcoding_accepted_containers_description": "Odaberite koji formati spremnika ne moraju biti remulksirani u MP4. Koristi se samo za određena pravila transkodiranja.", + "transcoding_accepted_video_codecs": "Prihvaćeni video kodeci", + "transcoding_accepted_video_codecs_description": "Odaberite koje video kodeke nije potrebno transkodirati. Koristi se samo za određena pravila transkodiranja.", + "transcoding_advanced_options_description": "Postavke većina korisnika ne treba mjenjati", + "transcoding_audio_codec": "Audio kodek", + "transcoding_audio_codec_description": "Opus je opcija s najvećom kvalitetom, no ima manju podršku s starim uređajima i softverima.", + "transcoding_bitrate_description": "Videozapisi veći od maksimalne brzine prijenosa ili nisu u prihvatljivom formatu", + "transcoding_codecs_learn_more": "Da biste saznali više o terminologiji koja se ovdje koristi, pogledajte FFmpeg dokumentaciju za H.264 kodek, HEVC kodek i VP9 kodek.", + "transcoding_constant_quality_mode": "Način stalne kvalitete", + "transcoding_constant_quality_mode_description": "ICQ je bolji od CQP-a, ali neki uređaji za hardversko ubrzanje ne podržavaju ovaj način rada. Postavljanje ove opcije daje prednost navedenom načinu rada kada se koristi kodiranje temeljeno na kvaliteti. NVENC je zanemaren jer ne podržava ICQ.", + "transcoding_constant_rate_factor": "Faktor konstantne stope (-crf)", + "transcoding_constant_rate_factor_description": "Razina kvalitete videa. Uobičajene vrijednosti su 23 za H.264, 28 za HEVC, 31 za VP9 i 35 za AV1. Niže je bolje, ali stvara veće datoteke.", + "transcoding_disabled_description": "Nemojte transkodirati nijedan videozapis, može prekinuti reprodukciju na nekim klijentima", + "transcoding_hardware_acceleration": "Hardversko Ubrzanje", + "transcoding_hardware_acceleration_description": "Eksperimentalno; puno brže, ali će imati nižu kvalitetu pri istoj bitrate postavci", + "transcoding_hardware_decoding": "Hardversko dekodiranje", + "transcoding_hardware_decoding_setting_description": "Odnosi se samo na NVENC, QSV i RKMPP. Omogućuje ubrzanje s kraja na kraj umjesto samo ubrzavanja kodiranja. Možda neće raditi na svim videozapisima.", + "transcoding_hevc_codec": "HEVC kodek", + "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_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", + "transcoding_preferred_hardware_device": "Preferirani hardverski uređaj", + "transcoding_preferred_hardware_device_description": "Odnosi se samo na VAAPI i QSV. Postavlja dri node koji se koristi za hardversko transkodiranje.", + "transcoding_preset_preset": "Preset (-preset)", + "transcoding_preset_preset_description": "Brzina kompresije. Sporije postavke proizvode manje datoteke i povećavaju kvalitetu pri ciljanju određene postavke bitratea. VP9 zanemaruje brzine iznad 'brže'.", + "transcoding_reference_frames": "Referentne slike", + "transcoding_reference_frames_description": "Broj slika za referencu prilikom komprimiranja određene slike. Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. 0 automatski postavlja ovu vrijednost.", + "transcoding_required_description": "Samo videozapisi koji nisu u prihvaćenom formatu", + "transcoding_settings": "Postavke Video Transkodiranja", + "transcoding_settings_description": "Upravljajte informacijama o razlučivosti i kodiranju video datoteka", + "transcoding_target_resolution": "Ciljana rezolucija", + "transcoding_target_resolution_description": "Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odziv aplikacije.", + "transcoding_temporal_aq": "Vremenski AQ", + "transcoding_temporal_aq_description": "Odnosi se samo na NVENC. Povećava kvalitetu scena s puno detalja i malo pokreta. Možda nije kompatibilan sa starijim uređajima.", + "transcoding_threads": "Sljedovi (Threads)", + "transcoding_threads_description": "Više vrijednosti dovode do bržeg kodiranja, ali ostavljaju manje prostora poslužitelju za obradu drugih zadataka dok je aktivan. Ova vrijednost ne smije biti veća od broja CPU jezgri. Maksimalno povećava iskorištenje ako je postavljeno na 0.", + "transcoding_tone_mapping": "Tonsko preslikavanje", + "transcoding_tone_mapping_description": "Pokušava sačuvati izgled HDR videozapisa kada se pretvori u SDR. Svaki algoritam čini različite kompromise za boju, detalje i svjetlinu. Hable čuva detalje, Mobius čuva boju, a Reinhard svjetlinu.", + "transcoding_tone_mapping_npl": "Tone-mapping NPL", + "transcoding_tone_mapping_npl_description": "Boje će se prilagoditi tako da izgledaju normalno za zaslon ove svjetline. Suprotno intuiciji, niže vrijednosti povećavaju svjetlinu videa i obrnuto budući da kompenziraju svjetlinu zaslona. 0 automatski postavlja ovu vrijednost.", + "transcoding_transcode_policy": "Pravila transkodiranja", + "transcoding_transcode_policy_description": "Pravila o tome kada se video treba transkodirati. HDR videozapisi uvijek će biti transkodirani (osim ako je transkodiranje onemogućeno).", + "transcoding_two_pass_encoding": "Kodiranje u dva prolaza", + "transcoding_two_pass_encoding_setting_description": "Transkodiranje u dva prolaza za proizvodnju bolje kodiranih videozapisa. Kada je omogućena maksimalna brzina prijenosa (potrebna za rad s H.264 i HEVC), ovaj način rada koristi raspon brzine prijenosa na temelju maksimalne brzine prijenosa i zanemaruje CRF. Za VP9, CRF se može koristiti ako je maksimalna brzina prijenosa onemogućena.", + "transcoding_video_codec": "Video Kodek", + "transcoding_video_codec_description": "VP9 ima visoku učinkovitost i web-kompatibilnost, ali treba dulje za transkodiranje. HEVC ima sličnu izvedbu, ali ima slabiju web kompatibilnost. H.264 široko je kompatibilan i brzo se transkodira, ali proizvodi mnogo veće datoteke. AV1 je najučinkovitiji kodek, ali nema podršku na starijim uređajima.", + "trash_enabled_description": "Omogućite značajke Smeća", + "trash_number_of_days": "Broj dana", + "trash_number_of_days_description": "Broj dana za držanje sredstava u smeću prije njihovog trajnog uklanjanja", + "trash_settings": "Postavke Smeća", + "trash_settings_description": "Upravljanje postavkama smeća", + "untracked_files": "Nepraćene datoteke", + "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_cleanup_job": "Čišćenje korisnika", + "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", + "user_delete_delay_settings": "Brisanje odgode", + "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", + "user_delete_immediately": "Račun i sredstva korisnika {user} bit će stavljeni u red čekanja za trajno brisanje odmah.", + "user_delete_immediately_checkbox": "Stavite korisnika i imovinu u red za trenutačno brisanje", + "user_management": "Upravljanje Korisnicima", + "user_password_has_been_reset": "Korisnička lozinka je poništena:", + "user_password_reset_description": "Molimo dostavite privremenu lozinku korisniku i obavijestite ga da će morati promijeniti lozinku pri sljedećoj prijavi.", + "user_restore_description": "Račun korisnika {user} bit će vraćen.", + "user_restore_scheduled_removal": "Vrati korisnika - zakazano uklanjanje {date, date, long}", + "user_settings": "Korisničke Postavke", + "user_settings_description": "Upravljanje korisničkim postavkama", + "user_successfully_removed": "Korisnik {email} je uspješno uklonjen.", + "version_check_enabled_description": "Omogući provjeru verzije", + "version_check_implications": "Značajka provjere verzije oslanja se na periodičnu komunikaciju s github.com", + "version_check_settings": "Provjera Verzije", + "version_check_settings_description": "Omogućite/onemogućite obavijest o novoj verziji", + "video_conversion_job": "Transkodiranje videozapisa", + "video_conversion_job_description": "Transkodiranje videozapisa za veću kompatibilnost s preglednicima i uređajima" + }, + "admin_email": "E-pošta administratora", + "admin_password": "Admin Lozinka", + "administration": "Administracija", + "advanced": "Napredno", + "age_months": "Dob {months, plural, one {# month} other {# months}}", + "age_year_months": "Dob 1 godina, {months, plural, one {# month} other {# months}}", + "age_years": "{years, plural, other {Age #}}", + "album_added": "Album dodan", + "album_added_notification_setting_description": "Primite obavijest e-poštom kada ste dodani u dijeljeni album", + "album_cover_updated": "Naslovnica albuma ažurirana", + "album_delete_confirmation": "Jeste li sigurni da želite izbrisati album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album dijeli, drugi korisnici mu više neće moći pristupiti.", + "album_info_updated": "Podaci o albumu ažurirani", + "album_leave": "Napustiti album?", + "album_leave_confirmation": "Jeste li sigurni da želite napustiti {album}?", + "album_name": "Naziv Albuma", + "album_options": "Opcije albuma", + "album_remove_user": "Ukloni korisnika?", + "album_remove_user_confirmation": "Jeste li sigurni da želite ukloniti {user}?", + "album_share_no_users": "Čini se da ste podijelili ovaj album sa svim korisnicima ili nemate nijednog korisnika s kojim biste ga dijelili.", + "album_updated": "Album ažuriran", + "album_updated_setting_description": "Primite obavijest e-poštom kada dijeljeni album ima nova sredstva", + "album_user_left": "Napušten {album}", + "album_user_removed": "Uklonjen {user}", + "album_with_link_access": "Dopusti svima s poveznicom pristup fotografijama i osobama u ovom albumu.", + "albums": "Albumi", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albumi}}", + "all": "Sve", + "all_albums": "Svi albumi", + "all_people": "Svi ljudi", + "all_videos": "Svi videi", + "allow_dark_mode": "Dozvoli tamni način", + "allow_edits": "Dozvoli izmjene", + "allow_public_user_to_download": "Dopusti javnom korisniku preuzimanje", + "allow_public_user_to_upload": "Dopusti javnom korisniku učitavanje", + "anti_clockwise": "Suprotno smjeru kazaljke na satu", + "api_key": "API Ključ", + "api_key_description": "Ova će vrijednost biti prikazana samo jednom. Obavezno ju kopirajte prije zatvaranja prozora.", + "api_key_empty": "Naziv vašeg API ključa ne smije biti prazan", + "api_keys": "API Ključevi", + "app_settings": "Postavke Aplikacije", + "appears_in": "Pojavljuje se u", + "archive": "Arhiva", + "archive_or_unarchive_photo": "Arhivirajte ili dearhivirajte fotografiju", + "archive_size": "Veličina arhive", + "archive_size_description": "Konfigurirajte veličinu arhive za preuzimanja (u GiB)", + "archived": "", + "archived_count": "{count, plural, other {Archived #}}", + "are_these_the_same_person": "Je li ovo ista osoba?", + "are_you_sure_to_do_this": "Jeste li sigurni da to želite učiniti?", + "asset_added_to_album": "Dodano u album", + "asset_adding_to_album": "Dodavanje u album...", + "asset_description_updated": "Opis imovine je ažuriran", + "asset_filename_is_offline": "Sredstvo {filename} je izvan mreže", + "asset_has_unassigned_faces": "Materijal ima nedodijeljena lica", + "asset_hashing": "Hashiranje...", + "asset_offline": "Sredstvo izvan mreže", + "asset_offline_description": "Ovaj materijal je izvan mreže. Immich ne može pristupiti lokaciji datoteke. Provjerite je li sredstvo dostupno, a zatim ponovno skenirajte biblioteku.", + "asset_skipped": "Preskočeno", + "asset_skipped_in_trash": "U smeću", + "asset_uploaded": "Učitano", + "asset_uploading": "Učitavanje...", + "assets": "Sredstva", + "assets_added_count": "Dodano {count, plural, one {# asset} other {# assets}}", + "assets_added_to_album_count": "Dodano {count, plural, one {# asset} other {# assets}} u album", + "assets_added_to_name_count": "Dodano {count, plural, one {# asset} other {# assets}} u {hasName, select, true {{name}} other {new album}}", + "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "{count, plural, one {# asset} other {# asset}} premješteno u smeće", + "assets_permanently_deleted_count": "Trajno izbrisano {count, plural, one {# asset} other {# assets}}", + "assets_removed_count": "Uklonjeno {count, plural, one {# asset} other {# assets}}", + "assets_restore_confirmation": "Jeste li sigurni da želite vratiti sve svoje resurse bačene u otpad? Ne možete poništiti ovu radnju!", + "assets_restored_count": "Vraćeno {count, plural, one {# asset} other {# assets}}", + "assets_trashed_count": "Bačeno u smeće {count, plural, one {# asset} other {# assets}}", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} već dio albuma", + "authorized_devices": "Ovlašteni Uređaji", + "back": "Nazad", + "back_close_deselect": "Natrag, zatvorite ili poništite odabir", + "backward": "Unazad", + "birthdate_saved": "Datum rođenja uspješno spremljen", + "birthdate_set_description": "Datum rođenja se koristi za izračunavanje godina ove osobe u trenutku fotografije.", + "blurred_background": "Zamućena pozadina", + "build": "Sagradi (Build)", + "build_image": "Sagradi (Build) Image", + "bulk_delete_duplicates_confirmation": "Jeste li sigurni da želite skupno izbrisati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i trajno izbrisati sve druge duplikate. Ne možete poništiti ovu radnju!", + "bulk_keep_duplicates_confirmation": "Jeste li sigurni da želite zadržati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će riješiti sve duplicirane grupe bez brisanja ičega.", + "bulk_trash_duplicates_confirmation": "Jeste li sigurni da želite na veliko baciti u smeće {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i baciti sve ostale duplikate u smeće.", + "buy": "Kupi Immich", + "camera": "Kamera", + "camera_brand": "Marka kamere", + "camera_model": "Model kamere", + "cancel": "Otkaži", + "cancel_search": "Otkaži pretragu", + "cannot_merge_people": "Nije moguće spojiti osobe", + "cannot_undo_this_action": "Ne možete poništiti ovu radnju!", + "cannot_update_the_description": "Nije moguće ažurirati opis", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "Promjena datuma", + "change_expiration_time": "Promjena vremena isteka", + "change_location": "Promjena lokacije", + "change_name": "Promjena imena", + "change_name_successfully": "Promijena imena uspješna", + "change_password": "Promjena Lozinke", + "change_password_description": "Ovo je ili prvi put da se prijavljujete u sustav ili je poslan zahtjev za promjenom lozinke. Unesite novu lozinku ispod.", + "change_your_password": "Promijenite lozinku", + "changed_visibility_successfully": "Vidljivost je uspješno promijenjena", + "check_all": "Provjeri Sve", + "check_logs": "Provjera Zapisa", + "choose_matching_people_to_merge": "Odaberite odgovarajuće osobe za spajanje", + "city": "Grad", + "clear": "Očisti", + "clear_all": "Očisti sve", + "clear_all_recent_searches": "Izbriši sva nedavna pretraživanja", + "clear_message": "Jasna poruka", + "clear_value": "Očisti vrijednost", + "clockwise": "U smjeru kazaljke na satu", + "close": "Zatvori", + "collapse": "Sažmi", + "collapse_all": "Sažmi sve", + "color": "Boja", + "color_theme": "Tema boja", + "comment_deleted": "Komentar izbrisan", + "comment_options": "Opcije komentara", + "comments_and_likes": "Komentari i lajkovi", + "comments_are_disabled": "Komentari onemogućeni", + "confirm": "Potvrdi", + "confirm_admin_password": "Potvrdite lozinku administratora", + "confirm_delete_shared_link": "Jeste li sigurni da želite izbrisati ovu zajedničku vezu?", + "confirm_password": "Potvrdite lozinku", + "contain": "Sadrži", + "context": "Kontekst", + "continue": "Nastavi", + "copied_image_to_clipboard": "Slika je kopirana u međuspremnik.", + "copied_to_clipboard": "Kopirano u međuspremnik!", + "copy_error": "Greška kopiranja", + "copy_file_path": "Kopiraj put datoteke", + "copy_image": "Kopiraj Sliku", + "copy_link": "Kopiraj poveznicu", + "copy_link_to_clipboard": "Kopiraj poveznicu u međuspremnik", + "copy_password": "Kopiraj lozinku", + "copy_to_clipboard": "Kopiraj u međuspremnik", + "country": "Država", + "cover": "Naslovnica", + "covers": "Naslovnice", + "create": "Kreiraj", + "create_album": "Kreiraj album", + "create_library": "Kreiraj Biblioteku", + "create_link": "Kreiraj poveznicu", + "create_link_to_share": "Izradite vezu za dijeljenje", + "create_link_to_share_description": "Dopusti svakome s vezom da vidi odabrane fotografije", + "create_new_person": "Stvorite novu osobu", + "create_new_person_hint": "Dodijelite odabrana sredstva novoj osobi", + "create_new_user": "Kreiraj novog korisnika", + "create_tag": "Stvori oznaku", + "create_tag_description": "Napravite novu oznaku. Za ugniježđene oznake unesite punu putanju oznake uključujući kose crte.", + "create_user": "Stvori korisnika", + "created": "Stvoreno", + "current_device": "Trenutačni uređaj", + "custom_locale": "Prilagođena Lokalizacija", + "custom_locale_description": "Formatiranje datuma i brojeva na temelju jezika i regije", + "dark": "Tamno", + "date_after": "Datum nakon", + "date_and_time": "Datum i Vrijeme", + "date_before": "Datum prije", + "date_of_birth_saved": "Datum rođenja uspješno spremljen", + "date_range": "Razdoblje", + "day": "Dan", + "deduplicate_all": "Dedupliciraj Sve", + "default_locale": "Zadana lokalizacija", + "default_locale_description": "Oblikujte datume i brojeve na temelju jezika preglednika", + "delete": "Izbriši", + "delete_album": "Izbriši album", + "delete_api_key_prompt": "Jeste li sigurni da želite izbrisati ovaj API ključ?", + "delete_duplicates_confirmation": "Jeste li sigurni da želite trajno izbrisati ove duplikate?", + "delete_key": "Ključ za brisanje", + "delete_library": "Izbriši knjižnicu", + "delete_link": "Izbriši poveznicu", + "delete_shared_link": "Izbriši dijeljenu poveznicu", + "delete_tag": "Izbriši oznaku", + "delete_tag_confirmation_prompt": "Jeste li sigurni da želite izbrisati oznaku {tagName}?", + "delete_user": "Izbriši korisnika", + "deleted_shared_link": "Izbrisana dijeljena poveznica", + "description": "Opis", + "details": "Detalji", + "direction": "Smjer", + "disabled": "Onemogućeno", + "disallow_edits": "Zabrani izmjene", + "discover": "Otkrij", + "dismiss_all_errors": "Odbaci sve pogreške", + "dismiss_error": "Odbaci pogrešku", + "display_options": "Mogućnosti prikaza", + "display_order": "Redoslijed prikaza", + "display_original_photos": "Prikaz originalnih fotografija", + "display_original_photos_setting_description": "Radije prikažite izvornu fotografiju kada gledate materijal umjesto sličica kada je izvorni materijal kompatibilan s webom. To može rezultirati sporijim brzinama prikaza fotografija.", + "do_not_show_again": "Ne prikazuj više ovu poruku", + "done": "Gotovo", + "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni videozapisi", + "download_include_embedded_motion_videos_description": "Uključite videozapise ugrađene u fotografije s pokretom kao zasebnu datoteku", + "download_settings": "Preuzmi", + "download_settings_description": "Upravljajte postavkama koje se odnose na preuzimanje sredstava", + "downloading": "Preuzimanje", + "downloading_asset_filename": "Preuzimanje materijala {filename}", + "drop_files_to_upload": "Ispustite datoteke bilo gdje za prijenos", + "duplicates": "Duplikati", + "duplicates_description": "Razriješite svaku grupu tako da naznačite koji su duplikati, ako ih ima", + "duration": "Trajanje", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "Izmjena", + "edit_album": "Uredi album", + "edit_avatar": "Uredi avatar", + "edit_date": "Uredi datum", + "edit_date_and_time": "Uredite datum i vrijeme", + "edit_exclusion_pattern": "Uredi uzorak izuzimanja", + "edit_faces": "Uređivanje lica", + "edit_import_path": "Uredi put uvoza", + "edit_import_paths": "Uredi Uvozne Putanje", + "edit_key": "Ključ za uređivanje", + "edit_link": "Uredi poveznicu", + "edit_location": "Uredi lokaciju", + "edit_name": "Uredi ime", + "edit_people": "Uredi ljude", + "edit_tag": "Uredi oznaku", + "edit_title": "Uredi Naslov", + "edit_user": "Uredi korisnika", + "edited": "Uređeno", + "editor": "Urednik", + "editor_close_without_save_prompt": "Promjene neće biti spremljene", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Omjeri stranica", + "editor_crop_tool_h2_rotation": "Rotacija", + "email": "E-pošta", + "empty_album": "", + "empty_trash": "Isprazni smeće", + "empty_trash_confirmation": "Jeste li sigurni da želite isprazniti smeće? Time će se iz Immicha trajno ukloniti sva sredstva u otpadu.\nNe možete poništiti ovu radnju!", + "enable": "Omogući", + "enabled": "Omogućeno", + "end_date": "Datum završetka", + "error": "Greška", + "error_loading_image": "Pogreška pri učitavanju slike", + "error_title": "Greška - Nešto je pošlo krivo", + "errors": { + "cannot_navigate_next_asset": "Nije moguće prijeći na sljedeći materijal", + "cannot_navigate_previous_asset": "Nije moguće prijeći na prethodni materijal", + "cant_apply_changes": "Nije moguće primijeniti promjene", + "cant_change_activity": "Ne mogu {enabled, select, true {disable} druge {enable}} aktivnosti", + "cant_change_asset_favorite": "Nije moguće promijeniti favorita za sredstvo", + "cant_change_metadata_assets_count": "Nije moguće promijeniti metapodatke {count, plural, one {# asset} other {# assets}}", + "cant_get_faces": "Ne mogu dobiti lica", + "cant_get_number_of_comments": "Ne mogu dobiti broj komentara", + "cant_search_people": "Ne mogu pretraživati ljude", + "cant_search_places": "Ne mogu pretraživati mjesta", + "cleared_jobs": "Izbrisani poslovi za: {job}", + "error_adding_assets_to_album": "Pogreška pri dodavanju materijala u album", + "error_adding_users_to_album": "Pogreška pri dodavanju korisnika u album", + "error_deleting_shared_user": "Pogreška pri brisanju dijeljenog korisnika", + "error_downloading": "Pogreška pri preuzimanju {filename}", + "error_hiding_buy_button": "Pogreška pri skrivanju gumba za kupnju", + "error_removing_assets_from_album": "Pogreška prilikom uklanjanja materijala iz albuma, provjerite konzolu za više pojedinosti", + "error_selecting_all_assets": "Pogreška pri odabiru svih sredstava", + "exclusion_pattern_already_exists": "Ovaj uzorak izuzimanja već postoji.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", + "failed_to_create_album": "Izrada albuma nije uspjela", + "failed_to_create_shared_link": "Stvaranje dijeljene veze nije uspjelo", + "failed_to_edit_shared_link": "Nije uspjelo uređivanje dijeljene poveznice", + "failed_to_get_people": "Dohvaćanje ljudi nije uspjelo", + "failed_to_load_asset": "Učitavanje sredstva nije uspjelo", + "failed_to_load_assets": "Učitavanje sredstava nije uspjelo", + "failed_to_load_people": "Učitavanje ljudi nije uspjelo", + "failed_to_remove_product_key": "Uklanjanje ključa proizvoda nije uspjelo", + "failed_to_stack_assets": "Slaganje sredstava nije uspjelo", + "failed_to_unstack_assets": "Nije uspjelo uklanjanje snopa sredstava", + "import_path_already_exists": "Ovaj uvozni put već postoji.", + "incorrect_email_or_password": "Netočna adresa e-pošte ili lozinka", + "paths_validation_failed": "{paths, plural, one {# putanja nije prošla} other {# putanje nisu prošle}} provjeru valjanosti", + "profile_picture_transparent_pixels": "Profilne slike ne smiju imati prozirne piksele. Povećajte i/ili pomaknite sliku.", + "quota_higher_than_disk_size": "Postavili ste kvotu veću od veličine diska", + "repair_unable_to_check_items": "Nije moguće provjeriti {count, select, one {item} other {items}}", + "unable_to_add_album_users": "Nije moguće dodati korisnike u album", + "unable_to_add_assets_to_shared_link": "Nije moguće dodati sredstva na dijeljenu poveznicu", + "unable_to_add_comment": "Nije moguće dodati komentar", + "unable_to_add_exclusion_pattern": "Nije moguće dodati uzorak izuzimanja", + "unable_to_add_import_path": "Nije moguće dodati putanju uvoza", + "unable_to_add_partners": "Nije moguće dodati partnere", + "unable_to_add_remove_archive": "Nije moguće {arhivirano, odabrati, istinito {ukloniti sredstvo iz} druge {dodati sredstvo u}} arhivu", + "unable_to_add_remove_favorites": "Nije moguće {favorite, select, true {add asset to} other {remove asset from}} favorite", + "unable_to_archive_unarchive": "Nije moguće {arhivirati, odabrati, istinito {arhivirati} ostalo {dearhivirati}}", + "unable_to_change_album_user_role": "Nije moguće promijeniti ulogu korisnika albuma", + "unable_to_change_date": "Nije moguće promijeniti datum", + "unable_to_change_favorite": "Nije moguće promijeniti favorita za sredstvo", + "unable_to_change_location": "Nije moguće promijeniti lokaciju", + "unable_to_change_password": "Nije moguće promijeniti lozinku", + "unable_to_change_visibility": "Nije moguće promijeniti vidljivost za {count, plural, one {# osobu} other {# osobe}}", + "unable_to_complete_oauth_login": "Nije moguće dovršiti OAuth prijavu", + "unable_to_connect": "Povezivanje nije moguće", + "unable_to_connect_to_server": "Nije moguće spojiti se na poslužitelj", + "unable_to_copy_to_clipboard": "Nije moguće kopirati u međuspremnik, provjerite pristupate li stranici putem https-a", + "unable_to_create_admin_account": "Nije moguće stvoriti administratorski račun", + "unable_to_create_api_key": "Nije moguće izraditi novi API ključ", + "unable_to_create_library": "Nije moguće stvoriti biblioteku", + "unable_to_create_user": "Nije moguće stvoriti korisnika", + "unable_to_delete_album": "Nije moguće izbrisati album", + "unable_to_delete_asset": "Nije moguće izbrisati sredstvo", + "unable_to_delete_assets": "Pogreška pri brisanju sredstava", + "unable_to_delete_exclusion_pattern": "Nije moguće izbrisati uzorak izuzimanja", + "unable_to_delete_import_path": "Nije moguće izbrisati put uvoza", + "unable_to_delete_shared_link": "Nije moguće izbrisati dijeljenu poveznicu", + "unable_to_delete_user": "Nije moguće izbrisati korisnika", + "unable_to_download_files": "Nije moguće preuzeti datoteke", + "unable_to_edit_exclusion_pattern": "Nije moguće urediti uzorak izuzimanja", + "unable_to_edit_import_path": "Nije moguće urediti put uvoza", + "unable_to_empty_trash": "Nije moguće isprazniti otpad", + "unable_to_enter_fullscreen": "Nije moguće otvoriti cijeli zaslon", + "unable_to_exit_fullscreen": "Nije moguće izaći iz cijelog zaslona", + "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", + "unable_to_get_shared_link": "Dohvaćanje dijeljene veze nije uspjelo", + "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati videozapis pokreta", + "unable_to_link_oauth_account": "Nije moguće povezati OAuth račun", + "unable_to_load_album": "Nije moguće učitati album", + "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstva", + "unable_to_load_items": "Nije moguće učitati stavke", + "unable_to_load_liked_status": "Nije moguće učitati status sviđanja", + "unable_to_log_out_all_devices": "Nije moguće odjaviti sve uređaje", + "unable_to_log_out_device": "Nije moguće odjaviti uređaj", + "unable_to_login_with_oauth": "Nije moguće prijaviti se pomoću OAutha", + "unable_to_play_video": "Nije moguće reproducirati video", + "unable_to_reassign_assets_existing_person": "Nije moguće ponovno dodijeliti imovinu na {name, select, null {postojeću osobu} other {{name}}}", + "unable_to_reassign_assets_new_person": "Nije moguće ponovno dodijeliti imovinu novoj osobi", + "unable_to_refresh_user": "Nije moguće osvježiti korisnika", + "unable_to_remove_album_users": "Nije moguće ukloniti korisnike iz albuma", + "unable_to_remove_api_key": "Nije moguće ukloniti API ključ", + "unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti sredstva iz dijeljene poveznice", + "unable_to_remove_library": "Nije moguće ukloniti biblioteku", + "unable_to_remove_offline_files": "Nije moguće ukloniti izvanmrežne datoteke", + "unable_to_remove_partner": "Nije moguće ukloniti partnera", + "unable_to_remove_reaction": "Nije moguće ukloniti reakciju", + "unable_to_repair_items": "Nije moguće popraviti stavke", + "unable_to_reset_password": "Nije moguće ponovno postaviti lozinku", + "unable_to_resolve_duplicate": "Nije moguće razriješiti duplikat", + "unable_to_restore_assets": "Nije moguće vratiti imovinu", + "unable_to_restore_trash": "Nije moguće vratiti otpad", + "unable_to_restore_user": "Nije moguće vratiti korisnika", + "unable_to_save_album": "Nije moguće spremiti album", + "unable_to_save_api_key": "Nije moguće spremiti API ključ", + "unable_to_save_date_of_birth": "Nije moguće spremiti datum rođenja", + "unable_to_save_name": "Nije moguće spremiti ime", + "unable_to_save_profile": "Nije moguće spremiti profil", + "unable_to_save_settings": "Nije moguće spremiti postavke", + "unable_to_scan_libraries": "Nije moguće skenirati knjižnice", + "unable_to_scan_library": "Nije moguće skenirati knjižnicu", + "unable_to_set_feature_photo": "Nije moguće postaviti istaknutu fotografiju", + "unable_to_set_profile_picture": "Nije moguće postaviti profilnu sliku", + "unable_to_submit_job": "Nije moguće poslati posao", + "unable_to_trash_asset": "Nije moguće baciti sredstvo u smeće", + "unable_to_unlink_account": "Nije moguće prekinuti vezu računa", + "unable_to_unlink_motion_video": "Nije moguće prekinuti vezu videozapisa pokreta", + "unable_to_update_album_cover": "Nije moguće ažurirati omot albuma", + "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", + "unable_to_update_library": "Nije moguće ažurirati biblioteku", + "unable_to_update_location": "Nije moguće ažurirati lokaciju", + "unable_to_update_settings": "Nije moguće ažurirati postavke", + "unable_to_update_timeline_display_status": "Nije moguće ažurirati status prikaza vremenske trake", + "unable_to_update_user": "Nije moguće ažurirati korisnika", + "unable_to_upload_file": "Nije moguće učitati datoteku" + }, + "exif": "Exif", + "exit_slideshow": "Izađi iz projekcije slideova", + "expand_all": "Proširi sve", + "expire_after": "Istječe nakon", + "expired": "Isteklo", + "expires_date": "Ističe {date}", + "explore": "Istraži", + "explorer": "Pretraživač (Explorer)", + "export": "Izvoz", + "export_as_json": "Izvezi kao JSON", + "extension": "Proširenje (Extension)", + "external": "Vanjski", + "external_libraries": "Vanjske Biblioteke", + "face_unassigned": "Nedodijeljeno", + "failed_to_get_people": "", + "favorite": "Omiljeno", + "favorite_or_unfavorite_photo": "Omiljena ili neomiljena fotografija", + "favorites": "Omiljene", + "feature_photo_updated": "Istaknuta fotografija ažurirana", + "features": "Značajke (Features)", + "features_setting_description": "Upravljajte značajkama aplikacije", + "file_name": "Naziv datoteke", + "file_name_or_extension": "Naziv ili ekstenzija datoteke", + "filename": "Naziv datoteke", + "filetype": "Vrsta datoteke", + "filter_people": "Filtrirajte ljude", + "find_them_fast": "Pronađite ih brzo po imenu pomoću pretraživanja", + "fix_incorrect_match": "Ispravite netočno podudaranje", + "folders": "Mape", + "folders_feature_description": "Pregledavanje prikaza mape za fotografije i videozapise u sustavu datoteka", + "force_re-scan_library_files": "Prisilno ponovno skeniraj sve datoteke biblioteke", + "forward": "Naprijed", + "general": "Općenito", + "get_help": "Potražite pomoć", + "getting_started": "Početak Rada", + "go_back": "Idi natrag", + "go_to_search": "Idi na pretragu", + "go_to_share_page": "", + "group_albums_by": "Grupiraj albume po...", + "group_no": "Nema grupiranja", + "group_owner": "Grupiraj po vlasniku", + "group_year": "Grupiraj po godini", + "has_quota": "Ima kvotu", + "hi_user": "Bok {name} ({email})", + "hide_all_people": "Sakrij sve ljude", + "hide_gallery": "Sakrij galeriju", + "hide_named_person": "Sakrij osobu {name}", + "hide_password": "Sakrij lozinku", + "hide_person": "Sakrij osobu", + "hide_unnamed_people": "Sakrij neimenovane osobe", + "host": "Domaćin", + "hour": "Sat", + "image": "Slika", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} snimljeno {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} i {person2} {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {additionalCount, number} drugih {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} i {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {additionalCount, number} drugih {date}", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich Web Sučelje", + "import_from_json": "Uvoz iz JSON-a", + "import_path": "Putanja uvoza", + "in_albums": "U {count, plural, one {# album} other {# albuma}}", + "in_archive": "U arhivi", + "include_archived": "Uključi arhivirano", + "include_shared_albums": "Uključi dijeljene albume", + "include_shared_partner_assets": "Uključite zajedničku imovinu partnera", + "individual_share": "Pojedinačni udio", + "info": "Informacije", + "interval": { + "day_at_onepm": "Svaki dan u 13 sati", + "hours": "{hours, plural, one {Svaki sat} few {Svakih {hours, number} sata} other {Svakih {hours, number} sati}}", + "night_at_midnight": "Svaku večer u ponoć", + "night_at_twoam": "Svake noći u 2 ujutro" + }, + "invite_people": "Pozovite ljude", + "invite_to_album": "Pozovi u album", + "items_count": "{count, plural, one {# datoteka} other {# datoteke}}", + "jobs": "Poslovi", + "keep": "Zadrži", + "keep_all": "Zadrži Sve", + "keyboard_shortcuts": "Prečaci tipkovnice", + "language": "Jezik", + "language_setting_description": "Odaberite željeni jezik", + "last_seen": "Zadnji put viđen", + "latest_version": "Najnovija verzija", + "latitude": "Zemljopisna širina", + "leave": "Izađi", + "let_others_respond": "Dozvoli da drugi odgovore", + "level": "Razina", + "library": "Biblioteka", + "library_options": "Mogućnosti biblioteke", + "light": "Svjetlo", + "like_deleted": "Like izbrisan", + "link_motion_video": "Povežite videozapis pokreta", + "link_options": "Opcije veze", + "link_to_oauth": "Veza na OAuth", + "linked_oauth_account": "Povezani OAuth račun", + "list": "Popis", + "loading": "Učitavanje", + "loading_search_results_failed": "Učitavanje rezultata pretraživanja nije uspjelo", + "log_out": "Odjavi se", + "log_out_all_devices": "Odjava sa svih uređaja", + "logged_out_all_devices": "Odjavljeni su svi uređaji", + "logged_out_device": "Odjavljen uređaj", + "login": "Prijava", + "login_has_been_disabled": "Prijava je onemogućena.", + "logout_all_device_confirmation": "Jeste li sigurni da želite odjaviti sve uređaje?", + "logout_this_device_confirmation": "Jeste li sigurni da se želite odjaviti s ovog uređaja?", + "longitude": "Zemljopisna dužina", + "look": "Izgled", + "loop_videos": "Ponavljajte videozapise", + "loop_videos_description": "Omogućite automatsko ponavljanje videozapisa u pregledniku detalja.", + "make": "Proizvođač", + "manage_shared_links": "Upravljanje dijeljenim vezama", + "manage_sharing_with_partners": "Upravljajte dijeljenjem s partnerima", + "manage_the_app_settings": "Upravljajte postavkama aplikacije", + "manage_your_account": "Upravljajte svojim računom", + "manage_your_api_keys": "Upravljajte svojim API ključevima", + "manage_your_devices": "Upravljajte uređajima na kojima ste prijavljeni", + "manage_your_oauth_connection": "Upravljajte svojom OAuth vezom", + "map": "Karta", + "map_marker_for_images": "Oznaka karte za slike snimljene u {city}, {country}", + "map_marker_with_image": "Oznaka karte sa slikom", + "map_settings": "Postavke karte", + "matches": "Podudaranja", + "media_type": "Vrsta medija", + "memories": "Sjećanja", + "memories_setting_description": "Upravljajte onim što vidite u svojim sjećanjima", + "memory": "Memorija", + "memory_lane_title": "Traka sjećanja {title}", + "menu": "Izbornik", + "merge": "Spoji", + "merge_people": "Spajanje ljudi", + "merge_people_limit": "Možete spojiti najviše 5 lica odjednom", + "merge_people_prompt": "Želite li spojiti ove ljude? Ova radnja je nepovratna.", + "merge_people_successfully": "Uspješno spajanje ljudi", + "merged_people_count": "{count, plural, one {# Spojena osoba} other {# Spojene osobe}}", + "minimize": "Minimiziraj", + "minute": "Minuta", + "missing": "Nedostaje", + "model": "Model", + "month": "Mjesec", + "more": "Više", + "moved_to_trash": "Premješteno u smeće", + "my_albums": "Moji albumi", + "name": "Ime", + "name_or_nickname": "Ime ili nadimak", + "never": "Nikada", + "new_album": "Novi Album", + "new_api_key": "Novi API ključ", + "new_password": "Nova lozinka", + "new_person": "Nova osoba", + "new_user_created": "Stvoren novi korisnik", + "new_version_available": "DOSTUPNA NOVA VERZIJA", + "newest_first": "Prvo najnovije", + "next": "Sljedeće", + "next_memory": "Sljedeće sjećanje", + "no": "Ne", + "no_albums_message": "Izradite album za organiziranje svojih fotografija i videozapisa", + "no_albums_with_name_yet": "Čini se da još nemate nijedan album s ovim imenom.", + "no_albums_yet": "Čini se da još nemate nijedan album.", + "no_archived_assets_message": "Arhivirajte fotografije i videozapise kako biste ih sakrili iz prikaza fotografija", + "no_assets_message": "KLIKNITE DA PRENESETE SVOJU PRVU FOTOGRAFIJU", + "no_duplicates_found": "Nisu pronađeni duplikati.", + "no_exif_info_available": "Nema dostupnih exif podataka", + "no_explore_results_message": "Prenesite više fotografija da istražite svoju zbirku.", + "no_favorites_message": "Dodajte favorite kako biste brzo pronašli svoje najbolje slike i videozapise", + "no_libraries_message": "Stvorite vanjsku biblioteku za pregled svojih fotografija i videozapisa", + "no_name": "Bez imena", + "no_places": "Nema mjesta", + "no_results": "Nema rezultata", + "no_results_description": "Pokušajte sa sinonimom ili općenitijom ključnom riječi", + "no_shared_albums_message": "Stvorite album za dijeljenje fotografija i videozapisa s osobama u svojoj mreži", + "not_in_any_album": "Ni u jednom albumu", + "note_apply_storage_label_to_previously_uploaded assets": "Napomena: Da biste primijenili Oznaku za skladištenje na prethodno prenesena sredstva, pokrenite", + "note_unlimited_quota": "napomena: Unesite 0 za neograni%C4%8Denu kvotu", + "notes": "Bilješke", + "notification_toggle_setting_description": "Omogući obavijesti putem e-pošte", + "notifications": "Obavijesti", + "notifications_setting_description": "Upravljanje obavijestima", + "oauth": "OAuth", + "offline": "Izvan mreže", + "offline_paths": "Izvanmrežne putanje", + "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", + "ok": "Ok", + "oldest_first": "Prvo najstarije", + "onboarding": "Uključivanje (Onboarding)", + "onboarding_privacy_description": "Sljedeće (neobavezne) značajke oslanjaju se na vanjske usluge i mogu se onemogućiti u bilo kojem trenutku u postavkama administracije.", + "onboarding_theme_description": "Odaberite temu boja za svoj primjer. To možete kasnije promijeniti u postavkama.", + "onboarding_welcome_description": "Postavimo vašu instancu s nekim uobičajenim postavkama.", + "onboarding_welcome_user": "Dobro došli, {user}", + "online": "Dostupan (Online)", + "only_favorites": "Samo omiljeno", + "only_refreshes_modified_files": "Osvježava samo izmijenjene datoteke", + "open_in_map_view": "Otvori u prikazu karte", + "open_in_openstreetmap": "Otvori u OpenStreetMap", + "open_the_search_filters": "Otvorite filtre pretraživanja", + "options": "Opcije", + "or": "ili", + "organize_your_library": "Organizirajte svoju knjižnicu", + "original": "original", + "other": "Ostalo", + "other_devices": "Ostali uređaji", + "other_variables": "Ostale varijable", + "owned": "Vlasništvo", + "owner": "Vlasnik", + "partner": "Partner", + "partner_can_access": "{partner} može pristupiti", + "partner_can_access_assets": "Sve vaše fotografije i videi osim onih u arhivi i smeću", + "partner_can_access_location": "Mjesto otkuda je slika otkinuta", + "partner_sharing": "Dijeljenje s partnerom", + "partners": "Partneri", + "password": "Zaporka", + "password_does_not_match": "Zaporka se ne podudara", + "password_required": "Zaporka je obavezna", + "password_reset_success": "Reset zaporke je uspješan", + "past_durations": { + "days": "{days, plural, one {Prošli dan} few {Prošlih # dana} other {Prošlih # dana}}", + "hours": "{hours, plural, one {Prošli sat} few {Prošla # sata} other {Prošlih # sati}}", + "years": "{years, plural, one {Prošle godine} few {Prošle # godine} other {Prošlih # godina}}" + }, + "path": "Putanja", + "pattern": "Uzorak", + "pause": "Pauza", + "pause_memories": "Pauziraj sjećanja", + "paused": "Pauzirano", + "pending": "Na čekanju", + "people": "Ljudi", + "people_edits_count": "Izmjenjeno {count, plural, one {# osoba} other {# osobe}}", + "people_feature_description": "Pregledavanje fotografija i videozapisa grupiranih po osobama", + "people_sidebar_description": "Prikažite poveznicu na Osobe na bočnoj traci", + "permanent_deletion_warning": "Upozorenje za nepovratno brisanje", + "permanent_deletion_warning_setting_description": "Prikaži upozorenje prilikom trajnog brisanja sredstava", + "permanently_delete": "Nepovratno obriši", + "permanently_delete_assets_count": "Trajno izbriši {count, plural, one {datoteku} other {datoteke}}", + "permanently_delete_assets_prompt": "Da li ste sigurni da želite trajni izbrisati {count, plural, one {ovu datoteku?} other {ove # datoteke?}}Ovo će ih također ukloniti {count, plural, one {iz njihovog} other {iz njihovih}} albuma.", + "permanently_deleted_asset": "Trajno izbrisano sredstvo", + "permanently_deleted_assets_count": "Trajno izbrisano {count, plural, one {# datoteka} other {# datoteke}}", + "person": "Osoba", + "person_hidden": "{name}{hidden, select, true { (skriveno)} other {}}", + "photo_shared_all_users": "Čini se da ste svoje fotografije podijelili sa svim korisnicima ili nemate nijednog korisnika s kojim biste ih podijelili.", + "photos": "Fotografije", + "photos_and_videos": "Fotografije i videozapisi", + "photos_count": "{count, plural, one {{count, number} fotografija} few {{count, number} fotografije} other {{count, number} fotografija}}", + "photos_from_previous_years": "Fotografije iz prethodnih godina", + "pick_a_location": "Odaberite lokaciju", + "place": "Mjesto", + "places": "Mjesta", + "play": "Pokreni", + "play_memories": "Pokreni sjećanja", + "play_motion_photo": "Reproduciraj Pokretnu fotografiju", + "play_or_pause_video": "Reproducirajte ili pauzirajte video", + "port": "Port", + "preset": "Unaprijed postavljeno", + "preview": "Pregled", + "previous": "Prethodno", + "previous_memory": "Prethodno sjećanje", + "previous_or_next_photo": "Prethodna ili sljedeća fotografija", + "primary": "Primarna (Primary)", + "privacy": "Privatnost", + "profile_image_of_user": "Profilna slika korisnika {user}", + "profile_picture_set": "Profilna slika postavljena.", + "public_album": "Javni album", + "public_share": "Javno dijeljenje", + "purchase_account_info": "Podržava softver", + "purchase_activated_subtitle": "Hvala što podržavate Immich i softver otvorenog koda", + "purchase_activated_time": "Aktivirano {date, date}", + "purchase_activated_title": "Vaš ključ je uspješno aktiviran", + "purchase_button_activate": "Aktiviraj", + "purchase_button_buy": "Kupi", + "purchase_button_buy_immich": "Kupi Immich", + "purchase_button_never_show_again": "Nikad više ne prikazuj", + "purchase_button_reminder": "Podsjeti me za 30 dana", + "purchase_button_remove_key": "Ukloni ključ", + "purchase_button_select": "Odaberite", + "purchase_failed_activation": "Aktivacija nije uspjela! Provjerite svoju e-poštu za točan ključ proizvoda!", + "purchase_individual_description_1": "Za pojedinca", + "purchase_individual_description_2": "Status podržavanja", + "purchase_individual_title": "Pojedinačna licenca", + "purchase_input_suggestion": "Imate ključ proizvoda? Unesite ključ ispod", + "purchase_license_subtitle": "Kupite Immich kako biste podržali kontinuirani razvoj usluge", + "purchase_lifetime_description": "Doživotna kupnja", + "purchase_option_title": "MOGUĆNOSTI KUPNJE", + "purchase_panel_info_1": "Za izgradnju Immicha potrebno je puno vremena i truda, a mi imamo inženjere koji rade na tome s punim radnim vremenom kako bismo ga učinili što boljim. Naša je misija da softver otvorenog koda i etička poslovna praksa postanu održivi izvor prihoda za programere i da se stvori ekosustav koji poštuje privatnost sa stvarnim alternativama eksploatacijskim uslugama u oblaku.", + "purchase_panel_info_2": "Budući da se obvezujemo da nećemo dodavati dodatne pretplate, ova vam kupnja neće dodijeliti nikakve dodatne značajke u Immichu. Oslanjamo se na korisnike poput vas da podržimo stalni razvoj Immicha.", + "purchase_panel_title": "Podrži projekt", + "purchase_per_server": "Po serveru", + "purchase_per_user": "Po korisniku", + "purchase_remove_product_key": "Ukloni ključ proizvoda", + "purchase_remove_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda?", + "purchase_remove_server_product_key": "Uklonite ključ proizvoda poslužitelja (Server)", + "purchase_remove_server_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda poslužitelja (Server)?", + "purchase_server_description_1": "Za cijeli server", + "purchase_server_description_2": "Status podupiratelja", + "purchase_server_title": "Poslužitelj (Server)", + "purchase_settings_server_activated": "Ključem proizvoda poslužitelja upravlja administrator", + "rating": "Broj zvjezdica", + "rating_clear": "Obriši ocjenu", + "rating_count": "{count, plural, one {# zvijezda} other {# zvijezde}}", + "rating_description": "Prikaži EXIF ocjenu na info ploči", + "reaction_options": "Mogućnosti reakcije", + "read_changelog": "Pročitajte Dnevnik promjena", + "reassign": "Ponovno dodijeli", + "reassigned_assets_to_existing_person": "Ponovo dodijeljeno{count, plural, one {# datoteka} other {# datoteke}} postojećoj {name, select, null {osobi} other {{name}}}", + "reassigned_assets_to_new_person": "Ponovo dodijeljeno {count, plural, one {# datoteka} other {# datoteke}} novoj osobi", + "reassing_hint": "Dodijelite odabrane datoteke postojećoj osobi", + "recent": "Nedavno", + "recent_searches": "Nedavne pretrage", + "refresh": "Osvježi", + "refresh_encoded_videos": "Osvježite kodirane videozapise", + "refresh_metadata": "Osvježi metapodatke", + "refresh_thumbnails": "Osvježi sličice", + "refreshed": "Osvježeno", + "refreshes_every_file": "Osvježava svaku datoteku", + "refreshing_encoded_video": "Osvježavanje kodiranog videa", + "refreshing_metadata": "Osvježavanje metapodataka", + "regenerating_thumbnails": "Obnavljanje sličica", + "remove": "Ukloni", + "remove_assets_album_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz albuma?", + "remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?", + "remove_assets_title": "Ukloniti datoteke?", + "remove_custom_date_range": "Ukloni prilagođeni datumski raspon", + "remove_deleted_assets": "", + "remove_from_album": "Ukloni iz albuma", + "remove_from_favorites": "Ukloni iz favorita", + "remove_from_shared_link": "Ukloni iz dijeljene poveznice", + "remove_user": "Ukloni korisnika", + "removed_api_key": "Uklonjen API ključ: {name}", + "removed_from_archive": "Uklonjeno iz arhive", + "removed_from_favorites": "Uklonjeno iz favorita", + "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", + "rename": "Preimenuj", + "repair": "Popravi", + "repair_no_results_message": "Nepraćene datoteke i datoteke koje nedostaju pojavit će se ovdje", + "replace_with_upload": "Zamijeni s prijenosom", + "repository": "Spremište (Repository)", + "require_password": "Zahtijevaj lozinku", + "require_user_to_change_password_on_first_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", + "reset": "Reset", + "reset_password": "Resetiraj lozinku", + "reset_people_visibility": "Poništi vidljivost ljudi", + "reset_to_default": "Vrati na zadano", + "resolve_duplicates": "Riješite duplikate", + "resolved_all_duplicates": "Razriješi sve duplikate", + "restore": "Oporavi", + "restore_all": "Oporavi sve", + "restore_user": "Vrati korisnika", + "restored_asset": "Obnovljena datoteka", + "resume": "Nastavi", + "retry_upload": "Ponovi prijenos", + "review_duplicates": "Pregledajte duplikate", + "role": "Uloga", + "role_editor": "Urednik", + "role_viewer": "Gledatelj", + "save": "Spremi", + "saved_api_key": "Spremljen API ključ", + "saved_profile": "Spremljen profil", + "saved_settings": "Spremljene postavke", + "say_something": "Reci nešto", + "scan_all_libraries": "Skeniraj sve Knjižnice", + "scan_all_library_files": "Ponovno skenirajte sve datoteke Knjižnice", + "scan_new_library_files": "Skeniraj nove datoteke Knjižnice", + "scan_settings": "Postavke skeniranja", + "scanning_for_album": "Skeniranje albuma...", + "search": "Pretraživanje", + "search_albums": "Traži albume", + "search_by_context": "Pretraživanje po kontekstu", + "search_by_filename": "Pretražujte prema nazivu datoteke ili ekstenziji", + "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", + "search_camera_make": "Pretražite marku kamere...", + "search_camera_model": "Pretražite model kamere...", + "search_city": "Pretražite grad...", + "search_country": "Pretražite državu...", + "search_for_existing_person": "Potražite postojeću osobu", + "search_no_people": "Nema ljudi", + "search_no_people_named": "Nema osoba s imenom \"{name}\"", + "search_options": "Opcije pretraživanja", + "search_people": "Traži ljude", + "search_places": "Traži mjesta", + "search_settings": "Postavke pretraživanja", + "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": "", + "select_trash_all": "", + "selected": "", + "send_message": "", + "send_welcome_email": "", + "server": "", + "server_stats": "", + "set": "", + "set_as_album_cover": "", + "set_as_profile_picture": "", + "set_date_of_birth": "", + "set_profile_picture": "", + "set_slideshow_to_fullscreen": "", + "settings": "", + "settings_saved": "", + "share": "", + "shared": "", + "shared_by": "", + "shared_by_you": "", + "shared_from_partner": "", + "shared_links": "", + "shared_with_partner": "", + "sharing": "", + "sharing_sidebar_description": "", + "show_album_options": "", + "show_and_hide_people": "", + "show_file_location": "", + "show_gallery": "", + "show_hidden_people": "", + "show_in_timeline": "", + "show_in_timeline_setting_description": "", + "show_keyboard_shortcuts": "", + "show_metadata": "", + "show_or_hide_info": "", + "show_password": "", + "show_person_options": "", + "show_progress_bar": "", + "show_search_options": "", + "shuffle": "", + "sign_out": "", + "sign_up": "", + "size": "", + "skip_to_content": "", + "slideshow": "", + "slideshow_settings": "", + "sort_albums_by": "", + "stack": "", + "stack_selected_photos": "", + "stacktrace": "", + "start": "", + "start_date": "", + "state": "", + "status": "", + "stop_motion_photo": "", + "stop_photo_sharing": "", + "stop_photo_sharing_description": "", + "stop_sharing_photos_with_user": "", + "storage": "", + "storage_label": "", + "storage_usage": "", + "submit": "", + "suggestions": "Prijedlozi", + "sunrise_on_the_beach": "Sunrise on the beach", + "swap_merge_direction": "", + "sync": "Sink.", + "template": "", + "theme": "Tema", + "theme_selection": "Izbor teme", + "theme_selection_description": "Automatski postavite temu na svijetlu ili tamnu ovisno o postavkama sustava vašeg preglednika", + "they_will_be_merged_together": "Oni ću biti spojeni zajedno", + "time_based_memories": "Uspomene temeljene na vremenu", + "timezone": "Vremenska zona", + "to_archive": "Arhivaj", + "to_change_password": "Promjeni lozinku", + "to_favorite": "Omiljeni", + "to_login": "Prijava", + "to_trash": "Smeće", + "toggle_settings": "Uključi/isključi postavke", + "toggle_theme": "Promjeni temu", + "toggle_visibility": "", + "total_usage": "Ukupna upotreba", + "trash": "Smeće", + "trash_all": "Stavi sve u smeće", + "trash_no_results_message": "Ovdje će se prikazati bačene fotografije i videozapisi.", + "trashed_items_will_be_permanently_deleted_after": "Stavke bačene u smeće trajno će se izbrisati nakon {days, plural, one {# day} other {# days}}.", + "type": "Vrsta", + "unarchive": "", + "unarchived": "", + "unfavorite": "", + "unhide_person": "", + "unknown": "", + "unknown_album": "", + "unknown_year": "", + "unlimited": "", + "unlink_oauth": "", + "unlinked_oauth_account": "", + "unselect_all": "", + "unstack": "", + "untracked_files": "", + "untracked_files_decription": "", + "up_next": "", + "updated_password": "", + "upload": "", + "upload_concurrency": "", + "url": "", + "usage": "", + "user": "", + "user_id": "", + "user_usage_detail": "", + "username": "", + "users": "", + "utilities": "", + "validate": "", + "variables": "", + "version": "", + "video": "", + "video_hover_setting": "", + "video_hover_setting_description": "", + "videos": "", + "videos_count": "", + "view_all": "", + "view_all_users": "", + "view_links": "", + "view_next_asset": "", + "view_previous_asset": "", + "viewer": "", + "waiting": "", + "week": "", + "welcome_to_immich": "", + "year": "", + "yes": "", + "you_dont_have_any_shared_links": "", + "zoom_image": "" +} diff --git a/i18n/hu.json b/i18n/hu.json new file mode 100644 index 0000000000..4bde75b5ef --- /dev/null +++ b/i18n/hu.json @@ -0,0 +1,1368 @@ +{ + "about": "Névjegy", + "account": "Fiók", + "account_settings": "Fiók Beállítások", + "acknowledge": "Megértettem", + "action": "Művelet", + "actions": "Műveletek", + "active": "Feldolgozás alatt", + "activity": "Tevékenység", + "activity_changed": "A tevékenység {enabled, select, true {bekapcsolva} other {kikapcsolva}}", + "add": "Hozzáadás", + "add_a_description": "Leírás hozzáadása", + "add_a_location": "Helyszín hozzáadása", + "add_a_name": "Név megadása", + "add_a_title": "Címadás", + "add_exclusion_pattern": "Kihagyási minta (pattern) hozzáadása", + "add_import_path": "Importálási útvonal hozzáadása", + "add_location": "Helyszín megadása", + "add_more_users": "További felhasználók hozzáadása", + "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_album": "Felvétel albumba", + "add_to_shared_album": "Felvétel megosztott albumba", + "added_to_archive": "Hozzáadva az archívumhoz", + "added_to_favorites": "Hozzáadva a kedvencekhez", + "added_to_favorites_count": "{count, number} hozzáadva a kedvencekhez", + "admin": { + "add_exclusion_pattern_description": "Kihagyási minták (pattern) megadása. A *, ** és ? helyettesítő karakterek engedélyezettek. Pl. a \"Raw\" könyvtárban tárolt összes fájl kihagyásához használható a \"**/Raw/**\". Minden \".tif\" fájl kihagyása az összes mappában: \"**/*.tif\". Abszolút elérési útvonal kihagyása: \"/kihagyni/kivant/mappa/**\".", + "asset_offline_description": "Ez a külső képtárban lévő elem már nem található, ezért a lomtárba került. Ha a fájl a képtáron belül lett áthelyezve, akkor ellenőrizd, hogy továbbra is látható az idővonaladon. Az elem visszaállításához győződj meg róla, hogy az alábbi mappa az Immich számára elérhető, majd újra átfésültesd át a képtárat.", + "authentication_settings": "Hitelesítési beállítások", + "authentication_settings_description": "Jelszó, OAuth és egyéb hitelesítési beállítások kezelése", + "authentication_settings_disable_all": "Biztosan letiltod az összes bejelentkezési módot? A bejelentkezés teljesen le lesz tiltva.", + "authentication_settings_reenable": "Az újbóli engedélyezéshez használj egySzerver Parancsot.", + "background_task_job": "Háttérfeladatok", + "check_all": "Összes Kipiálása", + "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?", + "confirm_delete_library_assets": "Biztosan kitörlöd ezt a képtárat? Ez kitörli az Immich-ből a benne lévő {count, plural, one {#} other {#}} elemet is, és ez nem visszavonható. A fájlok fizikailag a lemezen maradnak.", + "confirm_email_below": "A megerősítéshez írd be, hogy \"{email}\"", + "confirm_reprocess_all_faces": "Biztos vagy benne, hogy újra fel szeretnéd dolgozni az összes arcot? Ez a már elnevezett személyeket is törli.", + "confirm_user_password_reset": "Biztosan vissza szeretnéd állítani {user} jelszavát?", + "create_job": "Feladat létrehozása", + "crontab_guru": "Crontab Guru", + "disable_login": "Belépés letiltása", + "disabled": "Letiltva", + "duplicate_detection_job_description": "Gépi tanulás futtatása a hasonló elemek megtalálása céljából. Ez az Okos Keresés funkciót használja", + "exclusion_pattern_description": "A kihagyási minták (pattern) használatakor a mintának megfelelő fájlok vagy mappák át lesznek ugorva a képtár átfésülésekor. Akkor hasznos, ha a mappákban vannak olyan fájlok is, amelyeket nem szeretnél importálni, pl. nyers (RAW) fájlok.", + "external_library_created_at": "Külső képtár (létrehozva: {date})", + "external_library_management": "Külső Képtárak Kezelése", + "face_detection": "Arckeresés", + "face_detection_description": "Gépi tanulás segítségével megkeresi, hogy hol találhatóak arcok az elemeken. Videók esetében csak a bélyegképeken keres. \"Frissítés\" (újra) feldolgozza az összes elemet. \"Visszaállítás\" ezen felül törli az összes aktuális arcadatot. \"Hiányzók\" sorba állítja azokat az elemeket, amelyek eddig még nem lettek feldolgozva. A megtalált arcok ezután sorba lesznek állítva az Arcfelismeréshez, ami ezután az arcokat csoportosítja és meglevő vagy új személyekhez rendeli.", + "facial_recognition_job_description": "A megtalált arcokat személyekhez csoportosítja. Ez a lépés azután következik, amikor az Arckeresés lefutott. \"Visszaállítás\" (újra)csoportosítja az összes arcot. \"Hiányzók\" csak azokkal az arcokkal foglalkozik, amelyekhez még nincsen ember rendelve.", + "failed_job_command": "A(z) {command} parancs nem sikerült a következő feladathoz: {job}", + "force_delete_user_warning": "FIGYELEM: Ez azonnal eltávolítja a felhasználót és az összes hozzá tartozó elemet. A művelet nem visszavonható, és a fájlokat sem lehet később visszanyerni.", + "forcing_refresh_library_files": "A képtár összes fájljának frissítése", + "image_format": "Formátum", + "image_format_description": "WebP a JPEG-nél kisebb fájlokat készít, de lassabban.", + "image_prefer_embedded_preview": "Beágyazott előnézeti kép előnyben részesítése", + "image_prefer_embedded_preview_setting_description": "Nyers (RAW) fotók esetén használja a beépített előnézeti képet (ha van) a képek feldogozásához. Ez néhány kép esetében pontosabb színeket eredményezhet, de az előnézeti kép minősége erősen fényképezőgép függő, és a képen előfordulhatnak tömörítési hibák.", + "image_prefer_wide_gamut": "Széles színtér preferálása", + "image_prefer_wide_gamut_setting_description": "A bélyegképekhez DCI-P3 színtér használata. Ez a széles színteret használó képek esetén (pl: Adobe RGB, P3) jobban megőrzi az élénkebb színeket, de régebbi eszközökön vagy böngészőkben a kép színei másképpen jelenhetnek meg. Az sRGB képek a színeltolódások megelőzése érdekében nem változnak.", + "image_preview_description": "Közepes méretű kép eltávolított metaadatokkal, egy képes nézethez és a gépi tanuláshoz", + "image_preview_format": "Előnézet formátuma", + "image_preview_quality_description": "Előnézet minősége 1-100 között. A magasabb szám jobb minőséget, de nagyobb fájlokat eredményez és belassíthatja az alkalmazást. Túl alacsony érték befolyásolhatja a gépi tanulás pontosságát.", + "image_preview_resolution": "Előnézet felbontása", + "image_preview_resolution_description": "Fotó egyedüli nézetéhez használatos beállítás, valamint a gépi tanulás is ezt használja. Nagyobb felbontás több részletet megőriz, de tovább tart a folyamat, nagyobb fájl méretet eredményez, és befolyásolhatja az alkalmazás reakcióidejét.", + "image_preview_title": "Előnézet Beállításai", + "image_quality": "Minőség", + "image_quality_description": "Képminőség 1 és 100 között. A nagyobb érték jobb minőséget, de nagyobb fájlt eredményez. Ez a beállítás az Előnézeti képre és a Bélyegképre vonatkozik.", + "image_resolution": "Felbontás", + "image_resolution_description": "A nagyobb felbontás több részletet őriz meg, de lassabb létrehozni, nagyobb fájlt eredményez és belassíthatja az alkalmazást.", + "image_settings": "Képbeállítások", + "image_settings_description": "A létrehozott képek minőségi és felbontási beállításainak kezelése", + "image_thumbnail_description": "Kicsi bélyegkép eltávolított metaadatokkal, sok kis kép (pl idővonal) megjelenítéséhez", + "image_thumbnail_format": "Bélyegkép formátum", + "image_thumbnail_quality_description": "Bélyegkép minősége 1-100 között. A magasabb szám jobb minőséget, de nagyobb fájlméretet eredményez és belassíthatja az alkalmazást.", + "image_thumbnail_resolution": "Bélyegkép felbontás", + "image_thumbnail_resolution_description": "Képek csoportosított nézetekor használatos (idővonal, album nézet stb). Nagyobb felbontás esetén a kép részletgazdagabb marad, de tovább tart elkészíteni, nagyobb fájl méretet eredményes, és ronthatja az alkalmazás reagálását.", + "image_thumbnail_title": "Bélyegkép Beállítások", + "job_concurrency": "{job} párhuzamosság", + "job_created": "Feladat létrehozva", + "job_not_concurrency_safe": "Ez a feladat nem párhuzamosság-biztos.", + "job_settings": "Feladat Beállítások", + "job_settings_description": "Feladatok párhuzamosságának kezelése", + "job_status": "Feladat Állapota", + "jobs_delayed": "{jobCount, plural, other {# késik}}", + "jobs_failed": "{jobCount, plural, other {# sikertelen}}", + "library_created": "Képtár létrehozva: {library}", + "library_cron_expression": "Cron kifejezés", + "library_cron_expression_description": "Átfésülések közötti intervallum beállítása cron formátumban. Több információt találhatsz például itt: Crontab Guru", + "library_cron_expression_presets": "Cron kifejezés sablonok", + "library_deleted": "Képtár törölve", + "library_import_path_description": "Add meg az importálandó mappát. A rendszer ebben a mappában és összes almappájában fog képeket és videókat keresni.", + "library_scanning": "Időszakos Átfésülés", + "library_scanning_description": "A képtár időszakos átfésülésének beállítása", + "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_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", + "logging_enable_description": "Naplózás engedélyezése", + "logging_level_description": "Ha be van kapcsolva, milyen részletességű legyen a naplózás.", + "logging_settings": "Naplózás", + "machine_learning_clip_model": "CLIP modell", + "machine_learning_clip_model_description": "Egy CLIP modell neve az itt felsoroltak közül. A modell megváltoztatása után újra kell futtatni az 'Okos Keresés' feladatot minden képre.", + "machine_learning_duplicate_detection": "Duplikációk Keresése", + "machine_learning_duplicate_detection_enabled": "Duplikációk keresésének engedélyezése", + "machine_learning_duplicate_detection_enabled_description": "Ha ki van kapcsolva, a pontosan azonos elemek akkor sem lesznek duplikálva.", + "machine_learning_duplicate_detection_setting_description": "CLIP beágyazások használata a valószínű másolatok kereséséhez", + "machine_learning_enabled": "Gépi tanulás engedélyezése", + "machine_learning_enabled_description": "Ha ki van kapcsolva, a gépi tanulási képességek az alábbi beállításoktól függetlenül ki lesznek kapcsolva.", + "machine_learning_facial_recognition": "Arcfelismerés", + "machine_learning_facial_recognition_description": "A képekben szereplő arcok megkeresése, felismerése és csoportosítása", + "machine_learning_facial_recognition_model": "Arcfelismerési modell", + "machine_learning_facial_recognition_model_description": "A modellek méret szerint csökkenő sorrendben vannak felsorolva. A nagyobb modellek lassabbak és több memóriát használnak, de jobb eredményt produkálnak. Modellváltás után az összes képen futtasd újra az Arckeresés feladatot.", + "machine_learning_facial_recognition_setting": "Arckeresés engedélyezése", + "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Böngészés oldalon az Személyek szekcióban nem fog szerepelni senki.", + "machine_learning_max_detection_distance": "Maximum keresési távolság", + "machine_learning_max_detection_distance_description": "Két kép közötti maximális távolság, amely esetében még duplikációnak tekintendők (0.001 és 0.1 közötti érték). Minél magasabb az érték, annál több lesz a megtalált duplikáció, de a hamis találatok esélye is egyre nagyobb.", + "machine_learning_max_recognition_distance": "Maximum felismerési távolság", + "machine_learning_max_recognition_distance_description": "Két arc közötti maximális távolság, amely alapján ugyanazon személynek tekinthetők, 0 és 2 között. Ennek csökkentése megakadályozhatja, hogy két különböző személyt ugyanannak a személynek jelöljünk, míg a növelése megakadályozhatja, hogy ugyanazt a személyt két különböző személyként jelöljük. Vedd figyelembe, hogy könnyebb két személyt összevonni, mint egy személyt kettéválasztani, ezért lehetőség szerint inkább alacsonyabb küszöbértéket válassz.", + "machine_learning_min_detection_score": "Minimum keresési pontszám", + "machine_learning_min_detection_score_description": "Az arcok észleléséhez szükséges minimális megbízhatósági pontszám 0 és 1 között. Minél alacsonyabb az érték, annál több lesz a megtalált arc, de a hamis találatok esélye is egyre nagyobb.", + "machine_learning_min_recognized_faces": "Minimum felismert arc", + "machine_learning_min_recognized_faces_description": "Egy személy létrehozásához szükséges minimálisan felismert arcok száma. Ennek növelésével a arcfelismerés pontosabbá válik, azonban növeli annak az esélyét, hogy egy arc nem rendelődik hozzá egy személyhez.", + "machine_learning_settings": "Gépi Tanulási Beállítások", + "machine_learning_settings_description": "Gépi tanulási funkciók és beállítások kezelése", + "machine_learning_smart_search": "Okos Keresés", + "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", + "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", + "map_enable_description": "Térkép funkciók engedélyezése", + "map_gps_settings": "Térkép és GPS Beállítások", + "map_gps_settings_description": "A Térkép és GPS (Fordított Geokódolás) Beállításainak Kezelése", + "map_implications": "A térkép szolgáltatás egy külső csempeszolgáltatót használ (tiles.immich.cloud)", + "map_light_style": "Világos stílus", + "map_manage_reverse_geocoding_settings": "A Fordított Geokódolás beállításainak kezelése", + "map_reverse_geocoding": "Fordított Geokódolás", + "map_reverse_geocoding_enable_description": "Fordított geokódolás engedélyezése", + "map_reverse_geocoding_settings": "Fordított Geokódolási Beállítások", + "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", + "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", + "metadata_faces_import_setting_description": "Arcok importálása a kép EXIF adataiból és segédfájlokból", + "metadata_settings": "Metaadat Beállítások", + "metadata_settings_description": "Metaadat beállítások kezelése", + "migration_job": "Migrálás", + "migration_job_description": "Az elemek és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", + "no_paths_added": "Nincs megadva elérési útvonal", + "no_pattern_added": "Nincs megadva minta (pattern)", + "note_apply_storage_label_previous_assets": "Megjegyzés: Ha a korábban feltöltött elemekhez is szeretne Tárhely Címkéket társítani, akkor futtassa ezt", + "note_cannot_be_changed_later": "FIGYELEM: ezt később nem lehet megváltoztatni!", + "note_unlimited_quota": "Megjegyzés: 0 = korlátlan kvóta", + "notification_email_from_address": "Feladó cím", + "notification_email_from_address_description": "Küldő email címe, például: \"Immich Fotószerver \"", + "notification_email_host_description": "Email szerver kiszolgálója (pl. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Tanúsítvány hibák figyelmen kívül hagyása", + "notification_email_ignore_certificate_errors_description": "TLS tanúsítvány érvényességi hibák figyelmen kívül hagyása (nem ajánlott)", + "notification_email_password_description": "Az email szerverrel való hitelesítéshez használt jelszó", + "notification_email_port_description": "Email szerver portja (pl. 25, 465 vagy 587)", + "notification_email_sent_test_email_button": "Teszt email küldése és mentés", + "notification_email_setting_description": "Email értesítés küldés beállításai", + "notification_email_test_email": "Teszt email küldése", + "notification_email_test_email_failed": "Nem sikerült elküldeni a teszt emailt, ellenőrizd a beállításokat", + "notification_email_test_email_sent": "Egy teszt emailt küldtünk a(z) {email} címre. Figyeld a beérkező üzeneteidet.", + "notification_email_username_description": "Az email szerverrel való hitelesítéshez használt felhasználónév", + "notification_enable_email_notifications": "Email értesítések engedélyezése", + "notification_settings": "Értesítés Beállítások", + "notification_settings_description": "Értesítési és email beállítások kezelése", + "oauth_auto_launch": "Automatikus indítás", + "oauth_auto_launch_description": "Az OAuth bejelentkezési folyamat automatikus indítása a bejelentkezési oldal megnyitásakor", + "oauth_auto_register": "Automatikus regisztráció", + "oauth_auto_register_description": "Új felhasználók automatikus regisztrálása az OAuth használatával történő bejelentkezés után", + "oauth_button_text": "Gomb szövege", + "oauth_client_id": "Kliens ID", + "oauth_client_secret": "Kliens Titok", + "oauth_enable_description": "Bejelentkezés OAuth használatával", + "oauth_issuer_url": "Kibocsátó URL", + "oauth_mobile_redirect_uri": "Mobil átirányítási URI", + "oauth_mobile_redirect_uri_override": "Mobil átirányítási URI felülírás", + "oauth_mobile_redirect_uri_override_description": "Engedélyezd, ha az OAuth szolgáltató tiltja a mobil URI-t, mint például '{callback}'", + "oauth_profile_signing_algorithm": "Profil aláíró algoritmus", + "oauth_profile_signing_algorithm_description": "A felhasználói profil aláírásához használt algoritmus.", + "oauth_scope": "Hatókör", + "oauth_settings": "OAuth", + "oauth_settings_description": "OAuth bejelentkezési beállítások kezelése", + "oauth_settings_more_details": "Erről a funkcióról további információt a dokumentációban találsz.", + "oauth_signing_algorithm": "Aláírás algoritmusa", + "oauth_storage_label_claim": "Tárhely címke igénylés", + "oauth_storage_label_claim_description": "A felhasználó tárhely címkéjének automatikus beállítása az igényeltre.", + "oauth_storage_quota_claim": "Tárhelykvóta igénylése", + "oauth_storage_quota_claim_description": "A felhasználó tárhelykvótájának automatikus beállítása ennek az igényeltre.", + "oauth_storage_quota_default": "Alapértelmezett tárhelykvóta (GiB)", + "oauth_storage_quota_default_description": "Alapértelmezett tárhely kvóta GiB-ban, amennyiben a felhasználó nem jelezte az igényét (A korlátlan tárhelyhez 0-t adj meg).", + "offline_paths": "Offline Útvonalak", + "offline_paths_description": "Ezek az eredmények olyan fájlok kézi törlésének tudhatók be, amelyek nem részei külső képtárnak.", + "password_enable_description": "Bejelentkezés emaillel és jelszóval", + "password_settings": "Jelszavas Bejelentkezés", + "password_settings_description": "Jelszavas bejelentkezés beállítások kezelése", + "paths_validated_successfully": "Összes útvonal sikeresen érvényesítve", + "person_cleanup_job": "Személyek kipucolása", + "quota_size_gib": "Kvóta Mérete (GiB)", + "refreshing_all_libraries": "Összes képtár frissítése", + "registration": "Admin Regisztráció", + "registration_description": "Mivel ez az első felhasználó a rendszerben, ezért te leszel az Admin, aki az adminisztratív teendőkért felelős és további felhasználókat tud létrehozni.", + "removing_deleted_files": "Offline Fájlok eltávolítása", + "repair_all": "Összes Javítása", + "repair_matched_items": "{count, plural, one {# egyezés} other {# egyezés}}", + "repaired_items": "Javítva {count, plural, one {# fájl} other {# fájl}}", + "require_password_change_on_login": "Kötelező jelszómódosítás az első bejelentkezéskor", + "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", + "scanning_library_for_changed_files": "Képtár átfésülése megváltozott fájlok után", + "scanning_library_for_new_files": "Képtár átfésülése új fájlok után", + "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)", + "server_settings": "Szerver Beállítások", + "server_settings_description": "Szerver beállítások kezelése", + "server_welcome_message": "Üdvözlő üzenet", + "server_welcome_message_description": "A bejelentkezőoldalon megjelenő üzenet.", + "sidecar_job": "Segédfájl metaadatok", + "sidecar_job_description": "Metaadatok keresése vagy szinkronizálása a fájlrendszeren lévő segédfájlokból", + "slideshow_duration_description": "Az egyes képek megjelenítésének időtartama másodpercben", + "smart_search_job_description": "Gépi tanulás futtatása az elemeken, ami az Okos Kereséshez szükséges", + "storage_template_date_time_description": "Az elem készítési időpontja lesz felhasználva az időpont információhoz", + "storage_template_date_time_sample": "Példa időpont {date}", + "storage_template_enable_description": "Tárhely sablon motor engedélyezése", + "storage_template_hash_verification_enabled": "Hash ellenőrzés engedélyezve", + "storage_template_hash_verification_enabled_description": "Engedélyezi a hash-érték ellenőrzést - csak akkor kapcsold ki, ha tisztában vagy a következményekkel", + "storage_template_migration": "Tárhely sablon migrálása", + "storage_template_migration_description": "A jelenlegi {template} alkalmazása a már feltöltött elemekre", + "storage_template_migration_info": "A megváltozott sablon csak az újonnan feltöltött elemekre vonatkozik. A korábbi elemek visszamenőleges áthelyezéséhez ezt futtasd: {job}.", + "storage_template_migration_job": "Tárhely Sablon Migrációja", + "storage_template_more_details": "További részletekért erről a funkcióról lásd a Tárhely Sablon és annak következményeit a dokumentációban", + "storage_template_onboarding_description": "Ha ez a funkció engedélyezve van, akkor a fájlokat automatikusan az egyéni sablon alapján rendszerezi el. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért lásd a dokumentációt.", + "storage_template_path_length": "Útvonal hozzávetőleges maximális hossza: {length, number}{limit, number}", + "storage_template_settings": "Tárhely Sablon", + "storage_template_settings_description": "A feltöltött elemek mappaszerkezetének és fájl elnevezésének kezelése", + "storage_template_user_label": "A felhasználó Tárhely Címkéje {label}", + "system_settings": "Rendszerbeállítások", + "tag_cleanup_job": "Címkék kipucolása", + "theme_custom_css_settings": "Egyedi CSS", + "theme_custom_css_settings_description": "CSS Stíluslapokkal az Immich stílusa megváltoztatható.", + "theme_settings": "Téma Beállítások", + "theme_settings_description": "Az Immich webes felület testreszabásának kezelése", + "these_files_matched_by_checksum": "Ezek a fájlok egyeznek az ellenőrző összegük alapján", + "thumbnail_generation_job": "Bélyegképek Generálása", + "thumbnail_generation_job_description": "Nagy, kicsi és elmosódott bélyegképek létrehozása minden elemhez, valamint bélyegképek generálása minden személyhez", + "transcode_policy_description": "", + "transcoding_acceleration_api": "Gyorsító API", + "transcoding_acceleration_api_description": "Az átkódolás felgyorsításához használt eszközödhöz tartozó API. Ez a beállítás „legtöbb, amit megtehetünk” alapon működik: probléma esetén visszaáll szoftveres átkódolásra. A VP9 a hardvertől függően vagy működik, vagy nem.", + "transcoding_acceleration_nvenc": "NVENC (NVIDIA GPU-t igényel)", + "transcoding_acceleration_qsv": "Gyors Szinkronizálás (7. generációs vagy újabb Intel CPU-t igényel)", + "transcoding_acceleration_rkmpp": "RKMPP (csak Rockchip SOC-on)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Elfogadott audio kodekek", + "transcoding_accepted_audio_codecs_description": "Válaszd ki, hogy melyik audio kodekeket nem kell átkódolni. Csak bizonyos átkódolási szabályzatokhoz használt.", + "transcoding_accepted_containers": "Elfogadott tárolók", + "transcoding_accepted_containers_description": "Válaszd ki, hogy melyik tároló formátumokat nem szükséges átkódolni MP4 formátumba. Csak bizonyos átkódolási szabályzatokhoz használt.", + "transcoding_accepted_video_codecs": "Elfogadott videó kodekek", + "transcoding_accepted_video_codecs_description": "Válaszd ki, hogy mely videó kodekeket nem kell átkódolni. Csak bizonyos átkódolási szabályzatokhoz használt.", + "transcoding_advanced_options_description": "Ezeket az opciókat a legtöbb felhasználónak nem kell módosítania", + "transcoding_audio_codec": "Audio kodek", + "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb hangminőség ugyanakkora tárhelyen), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", + "transcoding_bitrate_description": "A maximum bitrátát meghaladó vagy nem megfelelő formátumú videókat", + "transcoding_codecs_learn_more": "Hogy többet tudj meg az itt felhasznált kifejezésekről, nézd meg az FFmpeg dokumentációt a H.264 kodekről, a HEVC kodekről és a VP9 kodekről.", + "transcoding_constant_quality_mode": "Állandó minőségű mód", + "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont az előbbit nem minden hardver támogatja. A rendszer az itt beállított módot preferálja a minőség orientált kódoláshoz. Az NVENC nem használja ezt a beállítást, mivel nem támogatja az ICQ-t.", + "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", + "transcoding_constant_rate_factor_description": "Videó minőségi szint. Tipikus értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", + "transcoding_disabled_description": "Ne kódolja át a videókat. Néhány kliensnél nem lejátszható videókhoz vezethet", + "transcoding_hardware_acceleration": "Hardveres Gyorsítás", + "transcoding_hardware_acceleration_description": "Kísérleti funkció. Sokkal gyorsabb, viszont azonos bitrátán is alacsonyabb minőséghez vezet", + "transcoding_hardware_decoding": "Hardveres dekódolás", + "transcoding_hardware_decoding_setting_description": "Lehetővé teszi az egész folyamat gyorsítását a pusztán kódolás gyorsítása helyett. Nem biztos, hogy minden videó esetén működik.", + "transcoding_hevc_codec": "HEVC kodek", + "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_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", + "transcoding_preferred_hardware_device": "Átkódoláshoz preferált hardver eszköz", + "transcoding_preferred_hardware_device_description": "Csak VAAPI vagy QSV esetén. Beállítja a hardveres átkódoláshoz használt DRI node-ot.", + "transcoding_preset_preset": "Előre Beállított (-preset)", + "transcoding_preset_preset_description": "Tömörítési sebesség. A lassabb beállítások kisebb fájlokat hoznak létre és növelik a minőséget az adott bitráta mellett. A VP9 kódolás figyelmen kívül hagyja a 'gyorsabb (faster)'-nél nagyobb sebességeket.", + "transcoding_reference_frames": "Referencia képkockák", + "transcoding_reference_frames_description": "A hivatkozott képkockák száma egy képkocka tömörítéséhez. Magasabb értékek növelik a tömörítési hatékonyságot, de lelassítják a kódolási folyamatot. 0 esetén a szoftver magának állítja be az értéket.", + "transcoding_required_description": "Csak az el nem fogadott formátumú videókat", + "transcoding_settings": "Videó Átkódolási Beállítások", + "transcoding_settings_description": "Videófájlok felbontásának és kódolásának kezelése", + "transcoding_target_resolution": "Célfelbontás", + "transcoding_target_resolution_description": "A magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart létrehozni, nagyobb fájlmérethez vezet és belassíthatja az alkalmazást.", + "transcoding_temporal_aq": "Időbeli (Temporal) AQ", + "transcoding_temporal_aq_description": "Csak NVENC esetén. Növeli a nagyon részletes, keveset mozgó videóanyag minőségét. Nem minden régi eszköz támogatja.", + "transcoding_threads": "Folyamatok száma", + "transcoding_threads_description": "Magas értékek esetén gyorsabban kódol, viszont kevesebb erőforrást hagy a szerver többi folyamatának. Nem ajánlott a CPU magjainak számánál nagyobb érték beállítása. A 0 érték maximalizálja a processzor kihasználását.", + "transcoding_tone_mapping": "Tónusleképezés (tone-mapping)", + "transcoding_tone_mapping_description": "Megpróbálja megőrizni a HDR videók kinézetét SDR-re való konvertálás során. Minden algoritmus különböző módon tesz kompromisszumot a színek, részletek, és a fényerő megőrzésében. A Hable inkább a részletek őrzi meg, a Mobius a színeket, a Reinhard pedig a fényerőt.", + "transcoding_tone_mapping_npl": "Tónusleképezés NPL", + "transcoding_tone_mapping_npl_description": "A színek úgy lesznek beállítva, hogy ezen a fényerőn lévő kijelzőn nézzenek ki jól. Alacsonyabb értékek esetén világosabb videót készít, és magasabb értékek esetén sötétebbet, mivel a kijelző fényerejéhez kompenzál. 0 esetén a szoftver magának állítja be az értéket.", + "transcoding_transcode_policy": "Átkódolási szabályzat", + "transcoding_transcode_policy_description": "Videó átkódolási szabályzat . HDR videók mindig átkódolásra kerülnek (kivéve, ha az átkódolás ki van kapcsolva).", + "transcoding_two_pass_encoding": "Átkódolás két menetben", + "transcoding_two_pass_encoding_setting_description": "A két menetben átkódolt videók jobb minőségűek lesznek. Ha engedélyezve van a bitráta maximalizálása (amely szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et. VP9 használata esetén a CRF használható, ha a bitráta nincs maximalizálva (azaz ki van kapcsolva).", + "transcoding_video_codec": "Videó Kodek", + "transcoding_video_codec_description": "VP9 hatékonyabb és kompatibilisebb webre, de tovább tart az átkódolás. HEVC hasonló teljesítményű, de több web kompatibilitási problémát okozhat. H.264 széles körben kompatibilis és gyors az átkódolása, de sokkal nagyobb fájlokat készít. AV1 a leghatékonyabb kodek, de régebbi eszközök nem támogatják.", + "trash_enabled_description": "Lomtár engedélyezése", + "trash_number_of_days": "Napok száma", + "trash_number_of_days_description": "Hány napig legyenek a lomtárban az elemek a végleges törlés előtt", + "trash_settings": "Lomtár Beállítások", + "trash_settings_description": "Lomtár beállítások kezelése", + "untracked_files": "Nem Nyilvántartott Fájlok", + "untracked_files_description": "Ezeket a fájlokat az alkalmazás nem tartja nyilván. Ez lehetséges például meghiúsult áthelyezés vagy megszakított feltöltés miatt, illetve valamilyen alkalmazáshiba következtében", + "user_cleanup_job": "Felhasználók kipucolása", + "user_delete_delay": "{user} felhasználói fiókja és elemei véglegesen törölve lesznek {delay, plural, one {# nap} other {# nap}} múlva.", + "user_delete_delay_settings": "Törlési késleltetés", + "user_delete_delay_settings_description": "Hány nappal az eltávolítás után legyen véglegesen törölve a felhasználó fiókja és tárolt elemei. A végleges törlés feladat minden éjfélkor fut le, hogy ellenőrizze, hogy van-e törlendő felhasználó. Ez a beállítás a következő futtatás során lép életbe.", + "user_delete_immediately": "{user} felhasználója és összes eleme azonnal sorba állításra kerül a végleges törléshez .", + "user_delete_immediately_checkbox": "Felhasználó és tárolt elemeinek sorba állítása azonnali törlésre", + "user_management": "Felhasználók Kezelése", + "user_password_has_been_reset": "A felhasználó jelszava megváltoztatásra került:", + "user_password_reset_description": "Juttasd el az átmeneti jelszót a felhasználóhoz és tájékoztasd, hogy a következő belépésnél azt majd meg kell változtatnia.", + "user_restore_description": "{user} felhasználója vissza lesz állítva.", + "user_restore_scheduled_removal": "Felhasználó visszaállítása - törlésre jelölve: {date, date, long}", + "user_settings": "Felhasználó Beállítások", + "user_settings_description": "Felhasználó beállítások kezelése", + "user_successfully_removed": "{email} felhasználó sikeresen törlésre került.", + "version_check_enabled_description": "Új verziók elérhetőségének ellenőrzése", + "version_check_implications": "Az új verziók ellenőrzése időszakos kommunikációt igényel a github.com oldallal", + "version_check_settings": "Verzió Ellenőrzés", + "version_check_settings_description": "Az új verzióról való értesítés be- és kikapcsolása", + "video_conversion_job": "Videók Átkódolása", + "video_conversion_job_description": "Videók átkódolása böngészőkkel és eszközökkel való széleskörű kompatibilitás érdekében" + }, + "admin_email": "Admin Email", + "admin_password": "Admin Jelszó", + "administration": "Adminisztráció", + "advanced": "Haladó", + "age_months": "Kor {months, plural, one {# hónap} other {# hónap}}", + "age_year_months": "Kor 1 év, {months, plural, one {# hónap} other {# hónap}}", + "age_years": "{years, plural, other {# év}}", + "album_added": "Album hozzáadva", + "album_added_notification_setting_description": "Email értesítőt kapsz, amikor hozzáadnak egy megosztott albumhoz", + "album_cover_updated": "Album borító frissítve", + "album_delete_confirmation": "Biztos, hogy ki szeretnéd törölni a(z) {album} albumot?", + "album_delete_confirmation_description": "Amennyiben ez egy megosztott album, a többi felhasználó sem fog tudni többé hozzáférni.", + "album_info_updated": "Album infó frissítve", + "album_leave": "Kilépsz az albumból?", + "album_leave_confirmation": "Biztos, hogy ki szeretnél lépni a(z) {album} albumból?", + "album_name": "Album Név", + "album_options": "Album beállítások", + "album_remove_user": "Felhasználó törlése?", + "album_remove_user_confirmation": "Biztos, hogy el szeretnéd távolítani {user} felhasználót?", + "album_share_no_users": "Úgy tűnik, hogy már minden felhasználóval megosztottad ezt az albumot, vagy nincs senki, akivel meg tudnád osztani.", + "album_updated": "Album frissült", + "album_updated_setting_description": "Küldjön email értesítőt, amikor egy megosztott albumhoz új elemeket adnak hozzá", + "album_user_left": "Kiléptél a(z) {album} albumból", + "album_user_removed": "{user} eltávolítva", + "album_with_link_access": "A link birtokában bárki láthatja a fotókat és a személyeket ebben az albumban.", + "albums": "Albumok", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Album}}", + "all": "Mind", + "all_albums": "Minden album", + "all_people": "Minden személy", + "all_videos": "Minden videó", + "allow_dark_mode": "Sötét téma engedélyezése", + "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", + "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.", + "api_key_empty": "Az API Kulcs név nem kéne, hogy üres legyen", + "api_keys": "API Kulcsok", + "app_settings": "Alkalmazás Beállítások", + "appears_in": "Itt szerepel", + "archive": "Archívum", + "archive_or_unarchive_photo": "Fotó archiválása vagy archiválásának visszavonása", + "archive_size": "Archívum mérete", + "archive_size_description": "Beállítja letöltésnél az archívum méretét (GiB)", + "archived": "Archíválva", + "archived_count": "{count, plural, other {Archiválva #}}", + "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_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_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...", + "assets": "Elemek", + "assets_added_count": "{count, plural, other {# elem}} hozzáadva", + "assets_added_to_album_count": "{count, plural, other {# elem}} hozzáadva az albumhoz", + "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva {hasName, select, true {a(z) {name}} other {az új}} albumhoz", + "assets_count": "{count, plural, other {# elem}}", + "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} áthelyezve a lomtárba", + "assets_permanently_deleted_count": "{count, plural, other {# elem}} véglegesen törölve", + "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", + "assets_restore_confirmation": "Biztos, hogy visszaállítod a lomtárban lévő összes elemet? Ez a művelet nem visszavonható! Megjegyzés: az offline elemeket nem lehet így visszaállítani.", + "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", + "assets_trashed_count": "{count, plural, other {# elem}} a lomtárba helyezve", + "assets_were_part_of_album_count": "{count, plural, other {# elem}} már eleve szerepelt az albumban", + "authorized_devices": "Engedélyezett Eszközök", + "back": "Vissza", + "back_close_deselect": "Vissza, bezárás, vagy kijelölés törlése", + "backward": "Visszafele", + "birthdate_saved": "Születésnap elmentve", + "birthdate_set_description": "A születés napját a rendszer arra használja, hogy kiírja, hogy a fénykép készítésekor a személy hány éves volt.", + "blurred_background": "Homályos háttér", + "bugs_and_feature_requests": "Hibabejelentés és Új Funkció Kérése", + "build": "Build", + "build_image": "Build Kép", + "bulk_delete_duplicates_confirmation": "Biztosan kitörölsz {count, plural, one {# duplikált elemet} other {# duplikált elemet}}? A művelet a legnagyobb méretű elemet tartja meg minden hasonló csoportból és minden másik duplikált elemet kitöröl. Ez a művelet nem visszavonható!", + "bulk_keep_duplicates_confirmation": "Biztosan meg szeretnél tartani {count, plural, other {# egyező elemet}}? Ez a művelet az elemek törlése nélkül megszünteti az összes duplikált csoportosítást.", + "bulk_trash_duplicates_confirmation": "Biztosan kitörölsz {count, plural, one {# duplikált fájlt} other {# duplikált fájlt}}? Ez a művelet megtartja minden csoportból a legnagyobb méretű elemet, és kitöröl minden másik duplikáltat.", + "buy": "Immich Megvásárlása", + "camera": "Fényképezőgép", + "camera_brand": "Fényképezőgép márka", + "camera_model": "Fényképezőgép modell", + "cancel": "Mégsem", + "cancel_search": "Keresés megszakítása", + "cannot_merge_people": "Személyek összevonása nem sikerült", + "cannot_undo_this_action": "Ez a művelet nem visszavonható!", + "cannot_update_the_description": "A leírás megváltoztatása nem sikerült", + "cant_apply_changes": "A változtatások nem alkalmazhatóak", + "cant_get_faces": "Az arcok nem elérhetőek", + "cant_search_people": "", + "cant_search_places": "A helyek nem kereshetőek", + "change_date": "Dátum változtatása", + "change_expiration_time": "Lejárati idő megváltoztatása", + "change_location": "Helyszín változtatása", + "change_name": "Név változtatása", + "change_name_successfully": "A név megváltoztatása sikeres", + "change_password": "Jelszócsere", + "change_password_description": "Most jelentkezel be a rendszerbe első alkalommal, vagy valaki jelszó-változtatást kezdeményezett. Kérjük, add meg az új jelszót.", + "change_your_password": "Jelszavad megváltoztatása", + "changed_visibility_successfully": "Láthatóság sikeresen megváltoztatva", + "check_all": "Mind Kijelöl", + "check_logs": "Hibanapló Megnyitása", + "choose_matching_people_to_merge": "Válaszd ki a megegyező személyeket összevonásra", + "city": "Város", + "clear": "Kitöröl", + "clear_all": "Alaphelyzet", + "clear_all_recent_searches": "Legutóbbi keresések törlése", + "clear_message": "Üzenet törlése", + "clear_value": "Érték törlése", + "clockwise": "Óramutató járásával megegyező irány", + "close": "Bezárás", + "collapse": "Összecsuk", + "collapse_all": "Mindet összecsuk", + "color": "Szín", + "color_theme": "Színtéma", + "comment_deleted": "Megjegyzés törölve", + "comment_options": "Megjegyzés beállítások", + "comments_and_likes": "Megjegyzések és reakciók", + "comments_are_disabled": "A megjegyzések le vannak tiltva", + "confirm": "Jóváhagy", + "confirm_admin_password": "Admin Jelszó Újból", + "confirm_delete_shared_link": "Biztosan törölni szeretnéd ezt a megosztott linket?", + "confirm_password": "Jelszó megerősítése", + "contain": "Belül", + "context": "Kontextus", + "continue": "Folytatás", + "copied_image_to_clipboard": "Kép a vágólapra másolva.", + "copied_to_clipboard": "Vágólapra másolva!", + "copy_error": "Másolási hiba", + "copy_file_path": "Fájlútvonal másolása", + "copy_image": "Kép Másolása", + "copy_link": "Link másolása", + "copy_link_to_clipboard": "Link másolása a vágólapra", + "copy_password": "Jelszó másolása", + "copy_to_clipboard": "Másolás a Vágólapra", + "country": "Ország", + "cover": "Kitöltés", + "covers": "Borítók", + "create": "Létrehoz", + "create_album": "Album létrehozása", + "create_library": "Képtár Létrehozása", + "create_link": "Link létrehozása", + "create_link_to_share": "Megosztási link létrehozása", + "create_link_to_share_description": "A kiválasztott fotókat mindenki láthassa, aki a linket használja", + "create_new_person": "Új személy létrehozása", + "create_new_person_hint": "A kiválasztott elemeket új személyhez rendelése", + "create_new_user": "Új felhasználó létrehozása", + "create_tag": "Címke létrehozása", + "create_tag_description": "Új címke létrehozása. Beágyazott címkék esetén add meg a címke teljes elérési útvonalát, beleértve a perjeleket is.", + "create_user": "Felhasználó létrehozása", + "created": "Készült", + "current_device": "Ez az eszköz", + "custom_locale": "Egyéni Területi Beállítás", + "custom_locale_description": "Dátumok és számok formázása a nyelv és terület szerint", + "dark": "Sötét", + "date_after": "Dátumtól", + "date_and_time": "Dátum és Idő", + "date_before": "Dátumig", + "date_of_birth_saved": "Születésnap sikeresen elmentve", + "date_range": "Dátum intervallum", + "day": "Nap", + "deduplicate_all": "Az Összes Deduplikálása", + "default_locale": "Alapértelmezett Területi Beállítás", + "default_locale_description": "Dátumok és számok formázása a böngésződ területi beállítása alapján", + "delete": "Törlés", + "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_key": "Kulcs törlése", + "delete_library": "Képtár Törlése", + "delete_link": "Link törlése", + "delete_shared_link": "Megosztott link törlése", + "delete_tag": "Címke törlése", + "delete_tag_confirmation_prompt": "Biztosan törölni szeretnéd a(z) {tagName} címkét?", + "delete_user": "Felhasználó törlése", + "deleted_shared_link": "Törölt megosztott link", + "deletes_missing_assets": "Törli a fizikailag hiányzó elemeket", + "description": "Leírás", + "details": "Részletek", + "direction": "Irány", + "disabled": "Letiltott", + "disallow_edits": "Módosítások letiltása", + "discord": "Discord", + "discover": "Felfedez", + "dismiss_all_errors": "Minden hiba elvetése", + "dismiss_error": "Hiba elvetése", + "display_options": "Megjelenítési beállítások", + "display_order": "Megjelenítési sorrend", + "display_original_photos": "Eredeti fotók megjelenítése", + "display_original_photos_setting_description": "Egy elem nézegetése közben jelenítse meg inkább az eredeti elemet a bélyegkép helyett, ha az is web-kompatibilis. Ez lelassíthatja a fotók megjelenítését.", + "do_not_show_again": "Ne mutassa többé ezt az üzenetet", + "documentation": "Dokumentáció", + "done": "Kész", + "download": "Letöltés", + "download_include_embedded_motion_videos": "Beágyazott videók", + "download_include_embedded_motion_videos_description": "Mozgó képekbe beágyazott videók mutatása külön fájlként", + "download_settings": "Letöltés", + "download_settings_description": "Elemek letöltésével kapcsolatos beállítások kezelése", + "downloading": "Letöltés", + "downloading_asset_filename": "{filename} elem letöltése", + "drop_files_to_upload": "A feltöltéshez húzd bárhova a fájlokat", + "duplicates": "Duplikátumok", + "duplicates_description": "Jelöld meg a duplikátumokat (ha léteznek) a csoportokban", + "duration": "Időtartam", + "durations": { + "days": "{days, plural, one {nap} other {{days, number} nap}}", + "hours": "{hours, plural, one {óra} other {{hours, number} óra}}", + "minutes": "{minutes, plural, one {perc} other {{minutes, number} perc}}", + "months": "{months, plural, one {hónap} other {{months, number} hónap}}", + "years": "{years, plural, one {év} other {{years, number} év}}" + }, + "edit": "Szerkesztés", + "edit_album": "Album módosítása", + "edit_avatar": "Profilkép módosítása", + "edit_date": "Dátum módosítása", + "edit_date_and_time": "Dátum és idő módosítása", + "edit_exclusion_pattern": "Kizárási minta (pattern) módosítása", + "edit_faces": "Arcok módosítása", + "edit_import_path": "Importálási útvonal módosítása", + "edit_import_paths": "Importálási Útvonalak Módosítása", + "edit_key": "Kulcs módosítása", + "edit_link": "Link módosítása", + "edit_location": "Hely módosítása", + "edit_name": "Név módosítása", + "edit_people": "Személyek módosítása", + "edit_tag": "Címke módosítása", + "edit_title": "Cím Módosítása", + "edit_user": "Felhasználó módosítása", + "edited": "Módosítva", + "editor": "Szerkesztő", + "editor_close_without_save_prompt": "A változtatások nem lesznek elmentve", + "editor_close_without_save_title": "Szerkesztő bezárása?", + "editor_crop_tool_h2_aspect_ratios": "Oldalarányok", + "editor_crop_tool_h2_rotation": "Forgatás", + "email": "Email", + "empty": "", + "empty_album": "Üres Album", + "empty_trash": "Lomtár ürítése", + "empty_trash_confirmation": "Biztosan kiüríted a lomtárat? Ez az Immich lomtárában lévő összes elemet véglegesen törli.\nEz a művelet nem visszavonható!", + "enable": "Engedélyezés", + "enabled": "Engedélyezve", + "end_date": "Vég dátum", + "error": "Hiba", + "error_loading_image": "Hiba a kép betöltése közben", + "error_title": "Hiba - valami félresikerült", + "errors": { + "cannot_navigate_next_asset": "Nem lehet a következő elemhez navigálni", + "cannot_navigate_previous_asset": "Nem lehet az előző elemhez navigálni", + "cant_apply_changes": "Nem lehet alkalmazni a változtatásokat", + "cant_change_activity": "Nem lehet {enabled, select, true {engedélyezni} other {kikapcsolni}} a tevékenységet", + "cant_change_asset_favorite": "Nem lehet a kedvenc állapotot megváltoztatni ehhez az elemhez", + "cant_change_metadata_assets_count": "Nem sikerült {count, plural, other {# elem}} metaadatát megváltoztatni", + "cant_get_faces": "Nem sikerült az arcok lekérdezése", + "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", + "cant_search_people": "Személyek keresése sikertelen", + "cant_search_places": "Helyek keresése sikertelen", + "cleared_jobs": "A(z) {job} feladat törölve", + "error_adding_assets_to_album": "Elemek albumhoz adása sikertelen", + "error_adding_users_to_album": "Felhasználók albumhoz adása sikertelen", + "error_deleting_shared_user": "Megosztott felhasználó törlése sikertelen", + "error_downloading": "{filename} letöltése sikertelen", + "error_hiding_buy_button": "A megvásárlás gomb elrejtése sikertelen", + "error_removing_assets_from_album": "Az elemek albumból való eltávolítása sikertelen - további információért ellenőrizd a konzol kimenetet", + "error_selecting_all_assets": "Az összes elem kijelölése sikertelen", + "exclusion_pattern_already_exists": "Ez a kizárási minta (pattern) már létezik.", + "failed_job_command": "A(z) {job} feladat {command} parancsa hibával zárult", + "failed_to_create_album": "Album készítése sikertelen", + "failed_to_create_shared_link": "Megosztott link készítése sikertelen", + "failed_to_edit_shared_link": "Megosztott link módosítása sikertelen", + "failed_to_get_people": "Személyek lekérdezése sikertelen", + "failed_to_load_asset": "Elem betöltése sikertelen", + "failed_to_load_assets": "Elemek betöltése sikertelen", + "failed_to_load_people": "Személyek betöltése sikertelen", + "failed_to_remove_product_key": "Termékkulcs eltávolítása sikertelen", + "failed_to_stack_assets": "Elemek csoportosítása sikertelen", + "failed_to_unstack_assets": "Csoportosított elemek szétszedése sikertelen", + "import_path_already_exists": "Ez az importálási útvonal már létezik.", + "incorrect_email_or_password": "Helytelen email vagy jelszó", + "paths_validation_failed": "A(z) {paths, plural, one {# elérési útvonal} other {# elérési útvonal}} érvényesítése sikertelen", + "profile_picture_transparent_pixels": "Profilképek nem tartalmazhatnak átlátszó pixeleket. Közelíts rá és/vagy mozgasd a képet.", + "quota_higher_than_disk_size": "Az elérhető lemezméretnél nagyobb kvótát állítottál be", + "repair_unable_to_check_items": "{count, select, one {elem} other {elem}} ellenőrzése sikertelen", + "unable_to_add_album_users": "Felhasználók albumhoz adása sikertelen", + "unable_to_add_assets_to_shared_link": "Elemeket megosztott linkhez adása sikertelen", + "unable_to_add_comment": "Hozzászólás sikertelen", + "unable_to_add_exclusion_pattern": "Kivétel minta (pattern) hozzáadása sikertelen", + "unable_to_add_import_path": "Importálási útvonal hozzáadása sikertelen", + "unable_to_add_partners": "Partnerek hozzáadása sikertelen", + "unable_to_add_remove_archive": "Az elem {archived, select, true {eltávolítása at Archívumból} other {hozzáadása Archívumhoz}} sikertelen", + "unable_to_add_remove_favorites": "Az elem {favorite, select, true {eltávolítása a Kedvencekből} other {hozzáadása a Kedvencekhez}} sikertelen", + "unable_to_archive_unarchive": "Az elem {archived, select, true {archiválása} other {kivétele az archívumból}} sikertelen", + "unable_to_change_album_user_role": "Az album felhasználói jogkörének megváltoztatása sikertelen", + "unable_to_change_date": "Dátum megváltoztatása sikertelen", + "unable_to_change_favorite": "Az elem kedvenc állapotának megváltoztatása sikertelen", + "unable_to_change_location": "Hely megváltoztatása sikertelen", + "unable_to_change_password": "Jelszó megváltoztatása sikertelen", + "unable_to_change_visibility": "{count, plural, other {# személy}} láthatóságának megváltoztatása sikertelen", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth bejelentkezés befejezése sikertelen", + "unable_to_connect": "Csatlakozás sikertelen", + "unable_to_connect_to_server": "Szerverhez csatlakozás sikertelen", + "unable_to_copy_to_clipboard": "Nem lehet a vágólapra másolni. Ellenőrizd, hogy az oldalt https-en keresztül használod-e", + "unable_to_create_admin_account": "Admin felhasználó létrehozása sikertelen", + "unable_to_create_api_key": "Új API kulcs létrehozása sikertelen", + "unable_to_create_library": "Képtár létrehozása sikertelen", + "unable_to_create_user": "Felhasználó létrehozása sikertelen", + "unable_to_delete_album": "Album törlése sikertelen", + "unable_to_delete_asset": "Elem törlése sikertelen", + "unable_to_delete_assets": "Hiba az elemek törlésekor", + "unable_to_delete_exclusion_pattern": "Kizárási minta (pattern) törlése sikertelen", + "unable_to_delete_import_path": "Import útvonal törlése sikertelen", + "unable_to_delete_shared_link": "Megosztott link törlése sikertelen", + "unable_to_delete_user": "Felhasználó törlése sikertelen", + "unable_to_download_files": "Fájlok letöltése sikertelen", + "unable_to_edit_exclusion_pattern": "Kizárási minta (pattern) módosítása sikertelen", + "unable_to_edit_import_path": "Import útvonal módosítása sikertelen", + "unable_to_empty_trash": "Lomtár ürítése sikertelen", + "unable_to_enter_fullscreen": "Teljes képernyőre váltás sikertelen", + "unable_to_exit_fullscreen": "Kilépés a teljes képernyős módból sikertelen", + "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", + "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", + "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_motion_video": "Motion videó összekapcsolása sikertelen", + "unable_to_link_oauth_account": "OAuth felhasználó hozzárendelése sikertelen", + "unable_to_load_album": "Album betöltése sikertelen", + "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", + "unable_to_load_items": "Elemek betöltése sikertelen", + "unable_to_load_liked_status": "Reakció-állapot betöltése sikertelen", + "unable_to_log_out_all_devices": "Kijelentkezés az összes eszközből sikertelen", + "unable_to_log_out_device": "Kijelentkezés az eszközről sikertelen", + "unable_to_login_with_oauth": "OAuth bejelentkezés sikertelen", + "unable_to_play_video": "Videó lejátszása sikertelen", + "unable_to_reassign_assets_existing_person": "Nem sikerült az elemeket hozzárendelni{name, select, null { egy meglévő személyhez} other {: {name}}}", + "unable_to_reassign_assets_new_person": "Elemek új személyhez rendelése sikertelen", + "unable_to_refresh_user": "Felhasználó frissítése sikertelen", + "unable_to_remove_album_users": "Felhasználó eltávolítása az albumból sikertelen", + "unable_to_remove_api_key": "API kulcs eltávolítása sikertelen", + "unable_to_remove_assets_from_shared_link": "Elemek eltávolítása a megosztott linkből sikertelen", + "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Offline fájlok eltávolítása sikertelen", + "unable_to_remove_library": "Képtár eltávolítása sikertelen", + "unable_to_remove_partner": "Partner eltávolítása sikertelen", + "unable_to_remove_reaction": "Reakció eltávolítása sikertelen", + "unable_to_remove_user": "", + "unable_to_repair_items": "Elemek javítása sikertelen", + "unable_to_reset_password": "Jelszó visszaállítása sikertelen", + "unable_to_resolve_duplicate": "Duplikátum feloldása sikertelen", + "unable_to_restore_assets": "Elemek visszaállítása sikertelen", + "unable_to_restore_trash": "Az összes elem visszaállítása sikertelen", + "unable_to_restore_user": "Felhasználó visszaállítása sikertelen", + "unable_to_save_album": "Album mentése sikertelen", + "unable_to_save_api_key": "API kulcs mentése sikertelen", + "unable_to_save_date_of_birth": "Születési időpont mentése sikertelen", + "unable_to_save_name": "Név mentése sikertelen", + "unable_to_save_profile": "Profil mentése sikertelen", + "unable_to_save_settings": "Beállítások mentése sikertelen", + "unable_to_scan_libraries": "A Képtárak átfésülése sikertelen", + "unable_to_scan_library": "A Képtár átfésülése sikertelen", + "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", + "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", + "unable_to_submit_job": "A feladat elindítása sikertelen", + "unable_to_trash_asset": "Elem lomtárba helyezése sikertelen", + "unable_to_unlink_account": "A fiók szétkapcsolása sikertelen", + "unable_to_unlink_motion_video": "A motion videó szétkapcsolása sikertelen", + "unable_to_update_album_cover": "Albumborító beállítása sikertelen", + "unable_to_update_album_info": "Album információ frissítése sikertelen", + "unable_to_update_library": "Képtár frissítése sikertelen", + "unable_to_update_location": "Hely módosítása sikertelen", + "unable_to_update_settings": "Beállítások módosítása sikertelen", + "unable_to_update_timeline_display_status": "Az idővonal megjelenítési státuszának frissítése sikertelen", + "unable_to_update_user": "Felhasználó módosítása sikertelen", + "unable_to_upload_file": "Fájlfeltöltés sikertelen" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "Exif", + "exit_slideshow": "Kilépés a Diavetítésből", + "expand_all": "Összes kinyitása", + "expire_after": "Lejárati idő", + "expired": "Lejárt", + "expires_date": "Lejár: {date}", + "explore": "Böngészés", + "explorer": "Böngésző", + "export": "Exportálás", + "export_as_json": "Exportálás JSON formátumban", + "extension": "Kiterjesztés", + "external": "Külső Képtár", + "external_libraries": "Külső Képtárak", + "face_unassigned": "Nincs hozzárendelve", + "failed_to_get_people": "Személyek lekérése sikertelen", + "favorite": "Kedvenc", + "favorite_or_unfavorite_photo": "Fotó kedvencnek jelölése vagy annak visszavonása", + "favorites": "Kedvencek", + "feature": "", + "feature_photo_updated": "Címlapkép frissítve", + "featurecollection": "", + "features": "Jellemzők", + "features_setting_description": "Az alkalmazás jellemzőinek kezelése", + "file_name": "Fájlnév", + "file_name_or_extension": "Fájlnév vagy kiterjesztés", + "filename": "Fájlnév", + "files": "", + "filetype": "Fájltípus", + "filter_people": "Személyek szűrése", + "find_them_fast": "Név alapján kereséssel gyorsan megtalálhatóak", + "fix_incorrect_match": "Hibás találat javítása", + "folders": "Mappák", + "folders_feature_description": "A fájlrendszerben lévő fényképek és videók mappanézetben való böngészése", + "force_re-scan_library_files": "Az összes Képtár fájl újbóli átfésülésének indítása", + "forward": "Előre", + "general": "Általános", + "get_help": "Segítségkérés", + "getting_started": "Kezdő Lépések", + "go_back": "Visszalépés", + "go_to_search": "Ugrás a kereséshez", + "go_to_share_page": "Ugrás a megosztás oldalhoz", + "group_albums_by": "Albumok csoportosítása...", + "group_no": "Nincs csoportosítás", + "group_owner": "Csoportosítás tulajdonos szerint", + "group_year": "Csoportosítás év szerint", + "has_quota": "Van kvótája", + "hi_user": "Szia {name} ({email})", + "hide_all_people": "Minden személy elrejtése", + "hide_gallery": "Galéria elrejtése", + "hide_named_person": "{name} elrejtése", + "hide_password": "Jelszó elrejtése", + "hide_person": "Személy elrejtése", + "hide_unnamed_people": "Név nélküli személyek elrejtése", + "host": "Kiszolgáló", + "hour": "Óra", + "image": "Kép", + "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma: {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Videó} other {Kép}} vele: {person1} (készült {date})", + "image_alt_text_date_2_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1} és {person2} (készült: {date})", + "image_alt_text_date_3_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és {person3} (készült: {date})", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} velük: {person1}, {person2} és további {additionalCount, number} személy (készült: {date})", + "image_alt_text_date_place": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city} (készült: {date})", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, vele: {person1} (készült: {date})", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1} és {person2} (készült: {date})", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és {person3} (készült: {date})", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Videó} other {Kép}} itt: {country}, {city}, velük: {person1}, {person2} és további {additionalCount, number} személy (készült: {date})", + "img": "", + "immich_logo": "Immich Logó", + "immich_web_interface": "Immich Webes Felület", + "import_from_json": "Importálás JSON formátumból", + "import_path": "Importálási útvonal", + "in_albums": "{count, plural, one {# albumban} other {# albumban}}", + "in_archive": "Archívumban", + "include_archived": "Archiváltakkal együtt", + "include_shared_albums": "Megosztott albumokkal együtt", + "include_shared_partner_assets": "Partner által megosztott elemekkel együtt", + "individual_share": "Egymagában megosztott elem", + "info": "Infó", + "interval": { + "day_at_onepm": "Minden nap 13 órakor", + "hours": "{hours, plural, one {óránként} other {{hours, number} óránként}}", + "night_at_midnight": "Minden éjjel éjfélkor", + "night_at_twoam": "Minden éjjel 2 órakor" + }, + "invite_people": "Személyek Meghívása", + "invite_to_album": "Meghívás az albumba", + "items_count": "{count, plural, other {# elem}}", + "job_settings_description": "", + "jobs": "Feladatok", + "keep": "Megtart", + "keep_all": "Összeset Megtart", + "keyboard_shortcuts": "Billentyűparancsok", + "language": "Nyelv", + "language_setting_description": "Válaszd ki preferált nyelvet", + "last_seen": "Utoljára láttuk", + "latest_version": "Legfrissebb Verzió", + "latitude": "Szélesség", + "leave": "Elhagyás", + "let_others_respond": "Mások is reagálhatnak", + "level": "Szint", + "library": "Képtár", + "library_options": "Képtár beállítások", + "light": "Világos", + "like_deleted": "Reakció törölve", + "link_motion_video": "Motion videó hozzárendelése", + "link_options": "Link beállítások", + "link_to_oauth": "Csatlakoztatás OAuth-hoz", + "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", + "list": "Lista", + "loading": "Betöltés", + "loading_search_results_failed": "Keresési eredmények betöltése sikertelen", + "log_out": "Kijelentkezés", + "log_out_all_devices": "Kijelentkezés Minden Eszközön", + "logged_out_all_devices": "Minden eszköz kijelentkeztetve", + "logged_out_device": "Eszköz kijelentkeztetve", + "login": "Bejelentkezés", + "login_has_been_disabled": "Bejelentkezés le van tiltva.", + "logout_all_device_confirmation": "Biztos, hogy minden eszközön ki szeretnél jelentkezni?", + "logout_this_device_confirmation": "Biztos, hogy ki szeretnél jelentkezni ezen az eszközön?", + "longitude": "Hosszúság", + "look": "Megjelenítés", + "loop_videos": "Videók ismétlése", + "loop_videos_description": "Engedélyezi a videók folyamatosan ismételt lejátszását.", + "main_branch_warning": "Fejlesztői verziót használsz. Javasoljuk a stabil verzió használatát!", + "make": "Gyártó", + "manage_shared_links": "Megosztási linkek kezelése", + "manage_sharing_with_partners": "Partnerekkel való megosztás kezelése", + "manage_the_app_settings": "Alkalmazás beállításainak kezelése", + "manage_your_account": "Saját fiókod kezelése", + "manage_your_api_keys": "Saját API kulcsok kezelése", + "manage_your_devices": "Bejelentkezett eszközök kezelése", + "manage_your_oauth_connection": "OAuth kapcsolódás kezelése", + "map": "Térkép", + "map_marker_for_images": "{country}, {city} helyen készült képek térképjelölője", + "map_marker_with_image": "Térképjelölő képpel", + "map_settings": "Térkép beállítások", + "matches": "Azonosak", + "media_type": "Médiatípus", + "memories": "Emlékek", + "memories_setting_description": "Állítsd be, hogy mik jelenjenek meg az emlékeid közt", + "memory": "Emlék", + "memory_lane_title": "Emlékek {title}", + "menu": "Menü", + "merge": "Összevonás", + "merge_people": "Személyek összevonása", + "merge_people_limit": "Egyszerre legfeljebb 5 arcot vonhatsz össze", + "merge_people_prompt": "Biztosan összevonod ezeket a személyeket? Ez a művelet nem visszavonható.", + "merge_people_successfully": "Személyek sikeresen összevonva", + "merged_people_count": "Összevonva {count, plural, other {# személy}}", + "minimize": "Kicsinyítés", + "minute": "Perc", + "missing": "Hiányzók", + "model": "Modell", + "month": "Hónap", + "more": "Továbbiak", + "moved_to_trash": "Áthelyezve a lomtárba", + "my_albums": "Saját albumaim", + "name": "Név", + "name_or_nickname": "Név vagy becenév", + "never": "Soha", + "new_album": "Új Album", + "new_api_key": "Új API Kulcs", + "new_password": "Új jelszó", + "new_person": "Új személy", + "new_user_created": "Új felhasználó létrehozva", + "new_version_available": "ÚJ VERZIÓ ÉRHETŐ EL", + "newest_first": "Legújabb először", + "next": "Következő", + "next_memory": "Következő emlék", + "no": "Nem", + "no_albums_message": "Fotóid és videóid rendszerezéséhez hozz létre egy új albumot", + "no_albums_with_name_yet": "Úgy tűnik, hogy ilyen névvel még nincs albumod.", + "no_albums_yet": "Úgy tűnik, hogy még egy albumod sincs.", + "no_archived_assets_message": "Archiváld a fényképeket és videókat, hogy elrejtsd azokat a Képek nézetből", + "no_assets_message": "KATTINTS AZ ELSŐ FÉNYKÉP FELTÖLTÉSÉHEZ", + "no_duplicates_found": "Nem találhatók duplikátumok.", + "no_exif_info_available": "Nincs elérhető Exif információ", + "no_explore_results_message": "Tölts fel több képet, hogy böngészhesd a gyűjteményed.", + "no_favorites_message": "Add hozzá a kedvencekhez, hogy gyorsan megtaláld a legjobb képeidet és videóidat", + "no_libraries_message": "Hozz létre külső képtárat a fényképeid és videóid megtekintéséhez", + "no_name": "Nincs Név", + "no_places": "Nincsenek helyek", + "no_results": "Nincs találat", + "no_results_description": "Próbálkozz szinonimákkal vagy általánosabb kulcsszavakkal", + "no_shared_albums_message": "Hozz létre egy új albumot, hogy megoszthasd fényképeid és videóid másokkal", + "not_in_any_album": "Nincs albumban", + "note_apply_storage_label_to_previously_uploaded assets": "Megjegyzés: a korábban feltöltött elemek Tárhely Címkézéséhez futtasd a(z)", + "note_unlimited_quota": "Megjegyzés: korlátlan kvótához írj 0-t", + "notes": "Megjegyzések", + "notification_toggle_setting_description": "Email értesítések engedélyezése", + "notifications": "Értesítések", + "notifications_setting_description": "Értesítések kezelése", + "oauth": "OAuth", + "official_immich_resources": "Hivatalos Immich Források", + "offline": "Offline", + "offline_paths": "Offline útvonalak", + "offline_paths_description": "Ezek az eredmények annak lehetnek köszönhetők, hogy manuálisan törölték azokat a fájlokat, amik nem részei egy külső képtárnak.", + "ok": "Rendben", + "oldest_first": "Legrégebbi először", + "onboarding": "Első lépések", + "onboarding_privacy_description": "Az alábbi (nem kötelező) funkciók külsős szolgáltatásokon alapulnak és bármikor kikapcsolhatóak az adminisztrációs beállításokban.", + "onboarding_theme_description": "Válassz egy színtémát. Ezt bármikor megváltoztathatod a beállításokban.", + "onboarding_welcome_description": "Állítsunk be néhány gyakori beállítást.", + "onboarding_welcome_user": "Üdvözöllek {user}", + "online": "Online", + "only_favorites": "Csak kedvencek", + "only_refreshes_modified_files": "Csak a megváltoztatott fájlokat frissíti", + "open_in_map_view": "Megnyitás térkép nézetben", + "open_in_openstreetmap": "Megnyitás OpenStreetMap-ben", + "open_the_search_filters": "Keresési szűrők megnyitása", + "options": "Beállítások", + "or": "vagy", + "organize_your_library": "Rendszerezd a képtáradat", + "original": "eredeti", + "other": "Egyéb", + "other_devices": "Egyéb eszközök", + "other_variables": "Egyéb változók", + "owned": "Tulajdonos", + "owner": "Tulajdonos", + "partner": "Partner", + "partner_can_access": "{partner} hozzáférhet", + "partner_can_access_assets": "Minden fényképed és videód, kivéve az Archiváltak és a Töröltek", + "partner_can_access_location": "A helyszín, ahol a fotókat készítették", + "partner_sharing": "Partner Megosztás", + "partners": "Partnerek", + "password": "Jelszó", + "password_does_not_match": "A jelszavak nem egyeznek", + "password_required": "Jelszó Szükséges", + "password_reset_success": "A jelszó visszaállítása sikeres", + "past_durations": { + "days": "{days, plural, one {Tegnap} other {Elmúlt # nap}}", + "hours": "{hours, plural, one {Előző óra} other {Elmúlt # óra}}", + "years": "{years, plural, one {Tavaly} other {Elmúlt # év}}" + }, + "path": "Útvonal", + "pattern": "Minta (Pattern)", + "pause": "Szüneteltetés", + "pause_memories": "Emlékek szüneteltetése", + "paused": "Szüneteltetve", + "pending": "Folyamatban lévő", + "people": "Személyek", + "people_edits_count": "{count, plural, other {# személy}} módosítva", + "people_feature_description": "Személyek szerint csoportosított fényképek és videók böngészése", + "people_sidebar_description": "Személyek link megjelenítése az oldalsávban", + "perform_library_tasks": "", + "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", + "permanent_deletion_warning_setting_description": "Figyelmeztessen elemek végleges törlése előtt", + "permanently_delete": "Végleges törlés", + "permanently_delete_assets_count": "{count, plural, one {Elem} other {Elemek}} végleges törlése", + "permanently_delete_assets_prompt": "Biztos, hogy véglegesen törölni {count, plural, one {szeretnéd ezt az elemet} other {szeretnél # elemet}}? Ez el fogja távolítani az {count, plural, one {elemet az albumokból, amikben szerepel} other {elemeket az albumokból, amikben szerepelnek}}.", + "permanently_deleted_asset": "Elem véglegesen törölve", + "permanently_deleted_assets_count": "{count, plural, other {# elem}} véglegesen törölve", + "person": "Személy", + "person_hidden": "{name}{hidden, select, true { (rejtett)} other {}}", + "photo_shared_all_users": "Úgy tűnik, hogy már mindenkivel megosztottad a fényképeidet, vagy nincs senki, akivel meg tudnád osztani.", + "photos": "Fényképek", + "photos_and_videos": "Fényképek és Videók", + "photos_count": "{count, plural, one {{count, number} Fotó} other {{count, number} Fotó}}", + "photos_from_previous_years": "Fényképek az előző évekből", + "pick_a_location": "Hely választása", + "place": "Hely", + "places": "Helyek", + "play": "Lejátszás", + "play_memories": "Emlékek lejátszása", + "play_motion_photo": "Mozgókép lejátszása", + "play_or_pause_video": "Videó elindítása vagy megállítása", + "point": "", + "port": "Port", + "preset": "Sablon", + "preview": "Előnézet", + "previous": "Előző", + "previous_memory": "Előző emlék", + "previous_or_next_photo": "Előző vagy következő fotó", + "primary": "Elsődleges", + "privacy": "Magánszféra", + "profile_image_of_user": "{user} profilképe", + "profile_picture_set": "Profilkép beállítva.", + "public_album": "Nyilvános album", + "public_share": "Nyilvános Megosztás", + "purchase_account_info": "Támogató", + "purchase_activated_subtitle": "Köszönjük, hogy támogattad az Immich-et és a nyílt forráskódú szoftvereket", + "purchase_activated_time": "Aktiválva ekkor: {date, date}", + "purchase_activated_title": "Kulcs sikeresen aktiválva", + "purchase_button_activate": "Aktiválás", + "purchase_button_buy": "Vásárlás", + "purchase_button_buy_immich": "Vásárold meg az Immich-et", + "purchase_button_never_show_again": "Soha többé ne mutassa", + "purchase_button_reminder": "Emlékeztessen 30 nap múlva", + "purchase_button_remove_key": "Kulcs eltávolítása", + "purchase_button_select": "Kiválaszt", + "purchase_failed_activation": "Sikertelen aktiválás! A helyes termékkulcsot az email fiókodban találhatod meg!", + "purchase_individual_description_1": "Egy magánszemélynek", + "purchase_individual_description_2": "Támogató állapot", + "purchase_individual_title": "Magánszemély", + "purchase_input_suggestion": "Van egy termékkulcsod? Add meg a kulcsot alább", + "purchase_license_subtitle": "Az Immich megvásárlásával támogasd a szolgáltatás folyamatos fejlesztését", + "purchase_lifetime_description": "Élettartamra szóló vásárlás", + "purchase_option_title": "VÁSÁRLÁSI LEHETŐSÉGEK", + "purchase_panel_info_1": "Az Immich készítése sok időt és erőfeszítést igényel, ezért főállásban foglalkoztatunk szoftvermérnököket, hogy annyira jó programmá tegyük, amennyire csak lehet. Küldetésünk, hogy a nyílt forráskódú szoftver és etikus üzleti gyakorlat fenntartható bevételi forrás legyen a fejlesztőknek, és hogy létrehozzunk egy magánszférát tiszteletben tartó ökoszisztémát, ami valódi alternatíváját jelenti a felhasználókat kizsákmányoló felhőalapú szolgáltatásoknak.", + "purchase_panel_info_2": "Mivel elkötelezettek vagyunk amellett, hogy ne vezessünk be csak pénzért elérhető extrákat, ezért ez a vásárlás nem biztosít új funkciókat az Immich-ben. Az Immich folyamatos fejlesztését az olyan felhasználók támogatására építjük mint Te.", + "purchase_panel_title": "Támogasd a projektet", + "purchase_per_server": "Szerverenként", + "purchase_per_user": "Felhasználónként", + "purchase_remove_product_key": "Termékkulcs Eltávolítása", + "purchase_remove_product_key_prompt": "Biztosan el szeretnéd távolítani a termékkulcsot?", + "purchase_remove_server_product_key": "Szerver termékkulcs eltávolítása", + "purchase_remove_server_product_key_prompt": "Biztosan el szeretnéd távolítani a szerver termékkulcsot?", + "purchase_server_description_1": "Az egész szerverre", + "purchase_server_description_2": "Támogató státusz", + "purchase_server_title": "Szerver", + "purchase_settings_server_activated": "A szerver termékkulcsot az admin kezeli", + "range": "", + "rating": "Értékelés csillagokkal", + "rating_clear": "Értékelés törlése", + "rating_count": "{count, plural, one {# csillag} other {# csillag}}", + "rating_description": "Exif értékelés megjelenítése az infópanelen", + "raw": "", + "reaction_options": "Reakció lehetőségek", + "read_changelog": "Változásnapló Elolvasása", + "reassign": "Hozzárendel", + "reassigned_assets_to_existing_person": "{count, plural, other {# elem}} hozzárendelve{name, select, null { egy létező személyhez} other {: {name}}}", + "reassigned_assets_to_new_person": "{count, plural, other {# elem}} hozzárendelve egy új személyhez", + "reassing_hint": "Kijelölt elemek létező személyhez rendelése", + "recent": "Friss", + "recent_searches": "Legutóbbi keresések", + "refresh": "Frissítés", + "refresh_encoded_videos": "Átkódolt videók frissítése", + "refresh_faces": "Arcok frissítése", + "refresh_metadata": "Metaadatok frissítése", + "refresh_thumbnails": "Bélyegképek frissítése", + "refreshed": "Frissítve", + "refreshes_every_file": "Minden létező és új fájl újraolvasása", + "refreshing_encoded_video": "Átkódolt videók frissítése folyamatban", + "refreshing_faces": "Arcok frissítése folyamatban", + "refreshing_metadata": "Metaadatok frissítése folyamatban", + "regenerating_thumbnails": "Bélyegképek újragenerálása folyamatban", + "remove": "Eltávolítás", + "remove_assets_album_confirmation": "Biztosan el szeretnél távolítani {count, plural, one {# elemet} other {# elemet}} az albumból?", + "remove_assets_shared_link_confirmation": "Biztosan el szeretnél távolítani {count, plural, one {# elemet} other {# elemet}} ebből a megosztott linkből?", + "remove_assets_title": "Elemek eltávolítása?", + "remove_custom_date_range": "Egyéni időintervallum eltávolítása", + "remove_deleted_assets": "Törölt Elemek Eltávolítása", + "remove_from_album": "Eltávolítás az albumból", + "remove_from_favorites": "Eltávolítás a kedvencekből", + "remove_from_shared_link": "Eltávolítás a megosztott linkből", + "remove_user": "Felhasználó eltávolítása", + "removed_api_key": "API Kulcs eltávolítva: {name}", + "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_tagged_assets": "Címke eltávolítva {count, plural, one {# elemről} other {# elemről}}", + "rename": "Átnevezés", + "repair": "Javítás", + "repair_no_results_message": "A nem nyilvántartott és a hiányzó fájlok itt jelennek meg", + "replace_with_upload": "Csere feltöltéssel", + "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", + "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", + "reset_settings_to_default": "", + "reset_to_default": "Visszaállítás alapállapotba", + "resolve_duplicates": "Duplikátumok feloldása", + "resolved_all_duplicates": "Minden duplikátum feloldása", + "restore": "Visszaállít", + "restore_all": "Minden visszaállítása", + "restore_user": "Felhasználó visszaállítása", + "restored_asset": "Visszaállított elem", + "resume": "Folytatás", + "retry_upload": "Feltöltés újrapróbálása", + "review_duplicates": "Duplikátumok áttekintése", + "role": "Jogkör", + "role_editor": "Szerkesztő", + "role_viewer": "Megjelenítő", + "save": "Mentés", + "saved_api_key": "API Kulcs Elmentve", + "saved_profile": "Profil elmentve", + "saved_settings": "Elmentett beállítások", + "say_something": "Szólj hozzá", + "scan_all_libraries": "Minden Képtár Átfésülése", + "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", + "scan_library": "Átfésülés", + "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", + "scan_settings": "Átfésülési Beállítások", + "scanning_for_album": "Albumok átfésülése...", + "search": "Keresés", + "search_albums": "Albumok keresése", + "search_by_context": "Keresés tartalom alapján", + "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_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_settings": "Keresési beállítások", + "search_state": "Megye/Állam keresése...", + "search_tags": "Címkék keresése...", + "search_timezone": "Időzóna keresése...", + "search_type": "Típus keresése", + "search_your_photos": "Fotóid keresése", + "searching_locales": "Helyszín keresése...", + "second": "Másodperc", + "see_all_people": "Minden személy megtekintése", + "select_album_cover": "Albumborító kiválasztása", + "select_all": "Összes kijelölése", + "select_all_duplicates": "Minden duplikátum kijelölése", + "select_avatar_color": "Avatár színének választása", + "select_face": "Arc kiválasztása", + "select_featured_photo": "Alapértelmezett fénykép kiválasztása", + "select_from_computer": "Kiválasztás a számítógépről", + "select_keep_all": "'Megtart' kijelölése", + "select_library_owner": "Válaszd ki a képtár tulajdonosát", + "select_new_face": "Új arc választása", + "select_photos": "Fotók választása", + "select_trash_all": "'Lomtár' kijelölése", + "selected": "Kiválasztott", + "selected_count": "{count, plural, other {# kiválasztva}}", + "send_message": "Üzenet küldése", + "send_welcome_email": "Üdvözlő email küldése", + "server": "Szerver", + "server_offline": "Szerver Nem Elérhető", + "server_online": "Szerver Elérhető", + "server_stats": "Szerver Statisztikák", + "server_version": "Szerver Verzió", + "set": "Beállít", + "set_as_album_cover": "Beállítás albumborítóként", + "set_as_profile_picture": "Beállítás profilképként", + "set_date_of_birth": "Születési dátum beállítása", + "set_profile_picture": "Profilkép beállítása", + "set_slideshow_to_fullscreen": "Diavetítés teljes képernyőre állítása", + "settings": "Beállítások", + "settings_saved": "Beállítások elmentve", + "share": "Megosztás", + "shared": "Megosztva", + "shared_by": "Megosztotta", + "shared_by_user": "{user} osztotta meg", + "shared_by_you": "Te osztottad meg", + "shared_from_partner": "{partner} fényképei", + "shared_link_options": "Megosztott link beállításai", + "shared_links": "Megosztott linkek", + "shared_photos_and_videos_count": "{assetCount, plural, other {# megosztott kép és videó.}}", + "shared_with_partner": "Megosztva {partner} partnereddel", + "sharing": "Megosztás", + "sharing_enter_password": "Add meg a jelszót az oldal megtekintéséhez.", + "sharing_sidebar_description": "Megosztás link megjelenítése az oldalsávban", + "shift_to_permanent_delete": "nyomd meg a ⇧ nyilat az elem végleges törléséhez", + "show_album_options": "Album beállítások mutatása", + "show_albums": "Albumok mutatása", + "show_all_people": "Minden személy mutatása", + "show_and_hide_people": "Személyek mutatása és elrejtése", + "show_file_location": "Fájl helyének mutatása", + "show_gallery": "Galéria mutatása", + "show_hidden_people": "Rejtett személyek mutatása", + "show_in_timeline": "Mutatás az idővonalon", + "show_in_timeline_setting_description": "Ennek a felhasználónak a képei és videói jelenjenek meg az idővonaladon", + "show_keyboard_shortcuts": "Billentyűparancsok mutatása", + "show_metadata": "Metaadatok mutatása", + "show_or_hide_info": "Info mutatása vagy elrejtése", + "show_password": "Jelszó mutatása", + "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_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", + "shuffle": "Véletlenszerű", + "sidebar": "Oldalsáv", + "sidebar_display_description": "Nézet link megjelenítése az oldalsávban", + "sign_out": "Kijelentkezés", + "sign_up": "Feliratkozás", + "size": "Méret", + "skip_to_content": "Ugrás a tartalomhoz", + "skip_to_folders": "Ugrás a mappákhoz", + "skip_to_tags": "Ugrás a címkékhez", + "slideshow": "Diavetítés", + "slideshow_settings": "Diavetítés beállításai", + "sort_albums_by": "Albumok rendezése...", + "sort_created": "Létrehozás dátuma", + "sort_items": "Elemek száma", + "sort_modified": "Módosítás dátuma", + "sort_oldest": "Legrégebbi fénykép", + "sort_recent": "Legújabb fénykép", + "sort_title": "Cím", + "source": "Forrás", + "stack": "Fotók csoportosítása", + "stack_duplicates": "Duplikátumok csoportosítása", + "stack_select_one_photo": "Válassz egy fő képet a csoportból", + "stack_selected_photos": "Kiválasztott fényképek csoportosítása", + "stacked_assets_count": "{count, plural, other {# elem}} csoportosítva", + "stacktrace": "Stacktrace", + "start": "Elindít", + "start_date": "Kezdő dátum", + "state": "Megye/Állam", + "status": "Állapot", + "stop_motion_photo": "Mozgókép Megállítása", + "stop_photo_sharing": "Fotóid megosztásának megszüntetése?", + "stop_photo_sharing_description": "{partner} mostantól nem fog tudni hozzáférni a fényképeidhez.", + "stop_sharing_photos_with_user": "Fényképeid megosztásának megszüntetése ezzel a felhasználóval", + "storage": "Tárhely", + "storage_label": "Tárhely címke", + "storage_usage": "{used}/{available} használatban", + "submit": "Beküldés", + "suggestions": "Javaslatok", + "sunrise_on_the_beach": "Napkelte a tengerparton", + "support": "Támogatás", + "support_and_feedback": "Támogatás és Visszajelzés", + "support_third_party_description": "Az Immich telepítésedet egy harmadik fél csomagolta. Mivel elképzelhető, hogy az esetlegesen felmerülő problémákat ez a csomag okozza, ezért kérjük, először velük közöld a problémákat az alábbi linkek segítségével.", + "swap_merge_direction": "Egyesítés irányának megfordítása", + "sync": "Szinkronizálás", + "tag": "Címke", + "tag_assets": "Elemek címkézése", + "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_updated": "Frissített címke: {tag}", + "tagged_assets": "{count, plural, one {# elem} other {# elem}} felcímkézve", + "tags": "Címkék", + "template": "Sablon", + "theme": "Téma", + "theme_selection": "Témaválasztás", + "theme_selection_description": "A böngésző beállításának megfelelően automatikusan használjon világos vagy sötét témát", + "they_will_be_merged_together": "Egyesítve lesznek", + "third_party_resources": "Harmadik Féltől Származó Források", + "time_based_memories": "Emlékek idő alapján", + "timezone": "Időzóna", + "to_archive": "Archiválás", + "to_change_password": "Jelszó megváltoztatása", + "to_favorite": "Kedvenc", + "to_login": "Bejelentkezés", + "to_parent": "Egy szinttel feljebb", + "to_trash": "Lomtárba helyezés", + "toggle_settings": "Beállítások átállítása", + "toggle_theme": "Sötét téma átváltása", + "toggle_visibility": "Láthatóság változtatása", + "total_usage": "Összesen használatban", + "trash": "Lomtár", + "trash_all": "Mindet lomtárba", + "trash_count": "{count, number} elem lomtárba helyezése", + "trash_delete_asset": "Elem Törlése / Lomtárba Helyezése", + "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videók.", + "trashed_items_will_be_permanently_deleted_after": "A lomtárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "type": "Típus", + "unarchive": "Archívumból kivesz", + "unarchived": "Archívumból kivett", + "unarchived_count": "{count, plural, other {# elem kivéve az archívumból}}", + "unfavorite": "Kedvenc közül kivesz", + "unhide_person": "Nem rejtett személy", + "unknown": "Ismeretlen", + "unknown_album": "Ismeretlen Album", + "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", + "unnamed_album": "Névtelen Album", + "unnamed_album_delete_confirmation": "Biztosan törölni szeretnéd ezt az albumot?", + "unnamed_share": "Névtelen Megosztás", + "unsaved_change": "Nem mentett változtatás", + "unselect_all": "Kijelölések megszüntetése", + "unselect_all_duplicates": "Duplikátumok kijelölésének megszüntetése", + "unstack": "Csoport Szétszedése", + "unstacked_assets_count": "{count, plural, other {# elemből}} álló csoport szétszedve", + "untracked_files": "Nem nyilvántartott fájlok", + "untracked_files_decription": "Ezeket a fájlokat az alkalmazás nem tartja nyilván. Ez lehetséges például meghiúsult áthelyezés vagy megszakított feltöltés miatt, illetve valamilyen alkalmazáshiba következtében", + "up_next": "Következik", + "updated_password": "Jelszó megváltoztatva", + "upload": "Feltöltés", + "upload_concurrency": "Párhuzamos feltöltés", + "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítsd az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", + "upload_skipped_duplicates": "{count, plural, other {# duplikátum}} kihagyva", + "upload_status_duplicates": "Duplikátumok", + "upload_status_errors": "Hibák", + "upload_status_uploaded": "Feltöltve", + "upload_success": "Feltöltés sikeres, frissítsd az oldalt az újonnan feltöltött elemek megtekintéséhez.", + "url": "URL", + "usage": "Használat", + "use_custom_date_range": "Szabadon megadott időintervallum használata", + "user": "Felhasználó", + "user_id": "Felhasználó azonosítója", + "user_liked": "{user} felhasználónak {type, select, photo {ez a fénykép} video {ez a videó} asset {ez az elem} other {ez}} tetszik", + "user_purchase_settings": "Megvásárlás", + "user_purchase_settings_description": "Vásárlás kezelése", + "user_role_set": "{user} felhasználónak {role} jogkör biztosítása", + "user_usage_detail": "Felhasználó használati adatai", + "username": "Felhasználónév", + "users": "Felhasználók", + "utilities": "Segédeszközök", + "validate": "Ellenőrzés", + "variables": "Változók", + "version": "Verzió", + "version_announcement_closing": "Barátsággal, Alex", + "version_announcement_message": "Szia barátom, az alkalmazásnak van egy új verziója. Kérjük, szánj időt a kiadási megjegyzések áttekintésére, és győződj meg róla, hogy a docker-compose.yml és az .env beállításaid naprakészek, hogy elkerüld a hibás konfigurációkat, különösen, ha a WatchTower-t vagy bármilyen automatikus frissítési megoldást használsz.", + "version_history": "Verziótörténet", + "version_history_item": "{version} telepítve: {date}", + "video": "Videó", + "video_hover_setting": "Kisméretű videó elindítása, ha az egér az elem felé megy", + "video_hover_setting_description": "Ha az egér az elem felé megy, akkor induljon el a kisméretű videó lejátszása. Még ha ez az opció ki is van kapcsolva, a lejátszás akkor is elindítható a lejátszás gombbal.", + "videos": "Videók", + "videos_count": "{count, plural, one {# Videó} other {# Videó}}", + "view": "Nézet", + "view_album": "Album Megtekintése", + "view_all": "Összes Megtekintése", + "view_all_users": "Minden Felhasználó Megtekintése", + "view_in_timeline": "Megtekintés az idővonalon", + "view_links": "Linkek megtekintése", + "view_next_asset": "Következő elem megtekintése", + "view_previous_asset": "Előző elem megtekintése", + "view_stack": "Csoport Megtekintése", + "viewer": "", + "visibility_changed": "{count, plural, other {# személy}} láthatósága megváltozott", + "waiting": "Várakozás", + "warning": "Figyelmeztetés", + "week": "Hét", + "welcome": "Üdvözlünk", + "welcome_to_immich": "Üdvözlünk az Immich-ben", + "year": "Év", + "years_ago": "{years, plural, one {# évvel} other {# évvel}} ezelőtt", + "yes": "Igen", + "you_dont_have_any_shared_links": "Nincsenek megosztott linkjeid", + "zoom_image": "Kép Nagyítása" +} diff --git a/web/src/lib/i18n/hy.json b/i18n/hy.json similarity index 99% rename from web/src/lib/i18n/hy.json rename to i18n/hy.json index f5273c4ac9..57651d0063 100644 --- a/web/src/lib/i18n/hy.json +++ b/i18n/hy.json @@ -172,7 +172,7 @@ "paths_validated_successfully": "", "quota_size_gib": "", "refreshing_all_libraries": "", - "removing_offline_files": "", + "removing_deleted_files": "", "repair_all": "", "repair_matched_items": "", "repaired_items": "", @@ -486,8 +486,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -720,10 +720,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "", "repair": "", diff --git a/web/src/lib/i18n/id.json b/i18n/id.json similarity index 85% rename from web/src/lib/i18n/id.json rename to i18n/id.json index ac29e27ec3..6b9a352dc8 100644 --- a/web/src/lib/i18n/id.json +++ b/i18n/id.json @@ -25,9 +25,10 @@ "add_to_shared_album": "Tambahkan ke album terbagi", "added_to_archive": "Ditambahkan ke arsip", "added_to_favorites": "Ditambahkan ke favorit", - "added_to_favorites_count": "Ditambahkan {count} ke favorit", + "added_to_favorites_count": "Ditambahkan {count, number} ke favorit", "admin": { "add_exclusion_pattern_description": "Tambahkan pola pengecualian. Glob menggunakan *, **, dan ? didukung. Untuk mengabaikan semua berkas dalam direktori apa pun bernama \"Raw\", gunakan \"**/Raw/**\". Untuk mengabaikan semua berkas berakhiran dengan \".tif\", gunakan \"**/*.tif\". Untuk mengabaikan jalur absolut, gunakan \"/jalur/untuk/diabaikan/**\".", + "asset_offline_description": "Aset pustaka eksternal ini tidak ada di diska dan telah dipindahkan ke tempat sampah. Jika berkasnya dipindah dalam pustaka, periksa lini masa Anda untuk aset baru yang cocok. Untuk memulihkan aset ini, pastikan jalur berkas di bawah dapat diakses oleh Immich dan pindai pustaka.", "authentication_settings": "Pengaturan Autentikasi", "authentication_settings_description": "Kelola kata sandi, OAuth, dan pengaturan autentikasi lainnya", "authentication_settings_disable_all": "Anda yakin untuk menonaktifkan semua cara login? Login akan dinonaktikan secara menyeluruh.", @@ -41,33 +42,44 @@ "confirm_email_below": "Untuk mengonfirmasi, ketik \"{email}\" di bawah", "confirm_reprocess_all_faces": "Apakah Anda yakin ingin memproses semua wajah? Ini juga akan menghapus nama orang.", "confirm_user_password_reset": "Apakah Anda yakin ingin mengatur ulang kata sandi {user}?", + "create_job": "Buat tugas", "disable_login": "Nonaktifkan log masuk", "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mendeteksi gambar yang serupa. Bergantung pada Pencarian Pintar", "exclusion_pattern_description": "Pola pengecualian memungkinkan Anda mengabaikan berkas dan folder ketika memindai pustaka Anda. Ini berguna jika Anda memiliki folder yang berisi berkas yang tidak ingin diimpor, seperti berkas RAW.", "external_library_created_at": "Pustaka eksternal (dibuat pada {date})", "external_library_management": "Pengelolaan Pustaka Eksternal", "face_detection": "Deteksi wajah", - "face_detection_description": "Deteksikan wajah dalam aset menggunakan pembelajaran mesin. Untuk video, hanya gambar kecilnya yang disertakan. \"Semua\" memproses (ulang) semua aset. \"Hilang\" mengantrekan aset yang belum diproses. Wajah yang dideteksi akan diantrekan untuk Pengenalan Wajah setelah Pendeteksian Wajah selesai, mengelompokkan mereka dalam orang yang sudah ada atau yang baru.", - "facial_recognition_job_description": "Kelompokkan wajah yang telah dideteksi menjadi orang. Langkah ini berjalan setelah Deteksi Wajah selesai. \"Semua\" mengelompokkan (ulang) semua wajah. \"Hilang\" mengantrekan wajah yang belum ditetapkan dengan seseorang.", + "face_detection_description": "Deteksikan wajah dalam aset menggunakan pembelajaran mesin. Untuk video, hanya gambar kecilnya yang disertakan. \"Segarkan\" memproses (ulang) semua aset. \"Segarkan\" juga menghapus data wajah terkini. \"Hilang\" mengantrekan aset yang belum diproses. Wajah yang dideteksi akan diantrekan untuk Pengenalan Wajah setelah Pendeteksian Wajah selesai, mengelompokkan mereka dalam orang yang sudah ada atau yang baru.", + "facial_recognition_job_description": "Kelompokkan wajah yang telah dideteksi menjadi orang. Langkah ini berjalan setelah Deteksi Wajah selesai. \"Segarkan\" mengelompokkan (ulang) semua wajah. \"Hilang\" mengantrekan wajah yang belum ditetapkan dengan seseorang.", "failed_job_command": "Perintah {command} gagal untuk tugas: {job}", "force_delete_user_warning": "PERINGATAN: Ini akan segera menghapus pengguna dan semua asetnya. Ini tidak dapat diurungkan dan semua berkasnya tidak dapat dipulihkan.", "forcing_refresh_library_files": "Memaksakan penyegaran semua berkas pustaka", + "image_format": "Format", "image_format_description": "WebP menghasilkan ukuran berkas yang lebih kecil daripada JPEG, tetapi lebih lambat untuk dienkode.", "image_prefer_embedded_preview": "Utamakan pratinjau tersemat", "image_prefer_embedded_preview_setting_description": "Gunakan pratinjau tersemat dalam foto RAW sebagai masukan dalam pemrosesan gambar ketika tersedia. Ini dapat menghasilkan warna yang lebih akurat untuk beberapa gambar, tetapi kualitas pratinjau bergantung pada kamera dan gambarnya dapat memiliki lebih banyak artefak kompresi.", "image_prefer_wide_gamut": "Utamakan gamut luas", "image_prefer_wide_gamut_setting_description": "Gunakan Display P3 untuk gambar kecil. Ini menjaga kecerahan gambar dengan ruang warna yang luas, tetapi gambar dapat terlihat beda pada perangkat lawas dengan versi peramban yang lawas. Gambar sRGB tetap dalam sRGB untuk menghindari perubahan warna.", + "image_preview_description": "Gambar berukuran sedang tanpa metadata, digunakan ketika melihat aset satuan dan untuk pembelajaran mesin", "image_preview_format": "Format pratinjau", + "image_preview_quality_description": "Kualitas pratinjau dari 1-100. Lebih tinggi lebih baik, tetapi menghasilkan berkas lebih besar dan respons aplikasi. Menetapkan nilai rendah dapat memengaruhi kualitas pembelajaran mesin.", "image_preview_resolution": "Resolusi pratinjau", "image_preview_resolution_description": "Digunakan ketika menampilkan satu foto untuk pembelajaran mesin. Resolusi yang lebih tinggi dapat menjaga lebih banyak detail tetapi dapat membutuhkan waktu lama untuk mengode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", + "image_preview_title": "Pengaturan Pratinjau", "image_quality": "Kualitas", "image_quality_description": "Kualitas gambar dari 1 sampai 100. Lebih tinggi baik untuk kualitas tetapi menghasilkan berkas lebih besar, opsi ini memengaruhi gambar Pratinjau dan Gambar Kecil.", + "image_resolution": "Resolusi", + "image_resolution_description": "Resolusi lebih tinggi dapat menjaga lebih banyak detail tetapi dapat memerlukan waktu lebih lama untuk dienkode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", "image_settings": "Pengaturan Gambar", "image_settings_description": "Kelola kualitas dan resolusi gambar yang dibuat", + "image_thumbnail_description": "Gambar kecil tanpa metadata, digunakan ketika melihat kelompok foto seperti lini masa utama", "image_thumbnail_format": "Format gambar kecil", + "image_thumbnail_quality_description": "Kualitas gambar kecil dari 1-100. Lebih tinggi lebih baik, tetapi menghasilkan berkas lebih besar dan dapat mengurangi respons aplikasi.", "image_thumbnail_resolution": "Resolusi gambar kecil", "image_thumbnail_resolution_description": "Digunakan ketika menampilkan kelompok foto (lini masa utama, tampilan album, dll.). Resolusi yang lebih tinggi dapat menjaga lebih banyak detail tetapi memerlukan waktu lama untuk mengode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", + "image_thumbnail_title": "Pengaturan Gambar Kecil", "job_concurrency": "Konkurensi {job}", + "job_created": "Tugas telah dibuat", "job_not_concurrency_safe": "Tugas ini tidak aman untuk konkurensi.", "job_settings": "Pengaturan Tugas", "job_settings_description": "Kelola konkurensi tugas", @@ -127,16 +139,21 @@ "map_enable_description": "Aktifkan fitur peta", "map_gps_settings": "Pengaturan Peta & GPS", "map_gps_settings_description": "Kelola Pengaturan Peta & GPS (Pengodean Geografis Terbalik)", + "map_implications": "Fitur peta mengandalkan layanan tile eksternal", "map_light_style": "Gaya terang", "map_manage_reverse_geocoding_settings": "Kelola settingan Pengodean Geografis Terbalik", "map_reverse_geocoding": "Pengodean Geografis Terbalik", "map_reverse_geocoding_enable_description": "Aktifkan pengodean geografis terbalik", "map_reverse_geocoding_settings": "Pengaturan Pengodean Geografis Terbalik", - "map_settings": "Pengaturan Peta", + "map_settings": "Peta", "map_settings_description": "Kelola pengaturan peta", "map_style_description": "URL ke tema peta style.json", "metadata_extraction_job": "Ekstrak metadata", - "metadata_extraction_job_description": "Ekstrak informasi metadata dari setiap aset, seperti GPS dan resolusi", + "metadata_extraction_job_description": "Ekstrak informasi metadata dari setiap aset, seperti GPS, wajah dan resolusi", + "metadata_faces_import_setting": "Aktifkan impor wajah", + "metadata_faces_import_setting_description": "Impor wajah dari data gambar EXIF dan berkas sidecar", + "metadata_settings": "Pengaturan Metadata", + "metadata_settings_description": "Kelola pengaturan metadata", "migration_job": "Migrasi", "migration_job_description": "Migrasikan gambar kecil untuk aset dan wajah ke struktur folder terkini", "no_paths_added": "Tidak ada jalur yang ditambahkan", @@ -145,7 +162,7 @@ "note_cannot_be_changed_later": "CATATAN: Ini tidak akan dapat diubah lagi!", "note_unlimited_quota": "Catatan: Masukkan 0 untuk kuota tidak terbatas", "notification_email_from_address": "Dari alamat", - "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", + "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", "notification_email_host_description": "Hos server surel (mis. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Abaikan eror sertifikat", "notification_email_ignore_certificate_errors_description": "Abaikan eror validasi sertifikat TLS (tidak disarankan)", @@ -171,7 +188,7 @@ "oauth_issuer_url": "URL Penerbit", "oauth_mobile_redirect_uri": "URI pengalihan ponsel", "oauth_mobile_redirect_uri_override": "Penimpaan URI penerusan ponsel", - "oauth_mobile_redirect_uri_override_description": "Aktifkan ketika 'app.immich:/' adalah URL penerusan yang tidak valid.", + "oauth_mobile_redirect_uri_override_description": "Aktifkan ketika provider OAuth tidak mengizinkan tautan mobile, seperti '{callback}'", "oauth_profile_signing_algorithm": "Algoritma penandatanganan profil", "oauth_profile_signing_algorithm_description": "Algoritma yang digunakan untuk menandatangani profil pengguna.", "oauth_scope": "Cakupan", @@ -191,19 +208,22 @@ "password_settings": "Log Masuk Kata Sandi", "password_settings_description": "Kelola pengaturan log masuk kata sandi", "paths_validated_successfully": "Semua jalur berhasil divalidasi", + "person_cleanup_job": "Pembersihan data pribadi", "quota_size_gib": "Ukuran Kuota (GiB)", "refreshing_all_libraries": "Menyegarkan semua pustaka", "registration": "Pendaftaran Admin", "registration_description": "Karena Anda merupakan pengguna pertama dalam sistem, Anda akan ditetapkan sebagai Admin dan bertanggung jawab atas tugas administratif dan pengguna tambahan akan dibuat oleh Anda.", - "removing_offline_files": "Menghapus Berkas Luring", + "removing_deleted_files": "Menghapus Berkas Luring", "repair_all": "Perbaiki Semua", "repair_matched_items": "{count, plural, one {# item} other {# item}} dicocokkan", "repaired_items": "{count, plural, one {# item} other {# item}} diperbaiki", "require_password_change_on_login": "Memerlukan pengguna untuk mengubah kata sandi pada log masuk pertama", "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", "scanning_library_for_changed_files": "Memindai pustaka untuk berkas yang telah diubah", "scanning_library_for_new_files": "Memindai pustaka untuk berkas baru", + "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)://", @@ -231,6 +251,7 @@ "storage_template_settings_description": "Kelola struktur folder dan nama berkas dari aset yang diunggah", "storage_template_user_label": "{label} adalah Label Penyimpanan pengguna", "system_settings": "Pengaturan Sistem", + "tag_cleanup_job": "Pembersihan tag", "theme_custom_css_settings": "CSS Kustom", "theme_custom_css_settings_description": "CSS memungkinkan desain Immich untuk diubah.", "theme_settings": "Pengaturan Tema", @@ -263,7 +284,7 @@ "transcoding_hardware_acceleration": "Akselerasi Perangkat Keras", "transcoding_hardware_acceleration_description": "Uji coba; lebih cepat, tetapi akan memiliki kualitas lebih rendah pada kecepatan bit yang sama", "transcoding_hardware_decoding": "Dekode perangkat keras", - "transcoding_hardware_decoding_setting_description": "Hanya diterapkan pada NVENC dan RKMPP. Mengaktifkan akselerasi ujung ke ujung daripada hanya mengakselerasi pengodean. Mungkin tidak berfungsi pada semua video.", + "transcoding_hardware_decoding_setting_description": "Mengaktifkan akselerasi ujung ke ujung daripada hanya mengakselerasi pengodean. Mungkin tidak berfungsi pada semua video.", "transcoding_hevc_codec": "Kodek HEVC", "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.", @@ -275,7 +296,7 @@ "transcoding_preferred_hardware_device": "Perangkat keras yang lebih disukai", "transcoding_preferred_hardware_device_description": "Hanya diterapkan pada VAAPI dan QSV. Menetapkan node dri yang digunakan untuk transkode perangkat keras.", "transcoding_preset_preset": "Prasetel (-preset)", - "transcoding_preset_preset_description": "Kecepatan pengompresan. Prasetel lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", + "transcoding_preset_preset_description": "Kecepatan kompresi. Pra setel yang lebih lambat membuat berkas lebih kecil dan meningkatkan kualitas ketika menargetkan kecepatan bit tertentu. VP9 mengabaikan kecepatan di atas `faster`.", "transcoding_reference_frames": "Bingkai referensi", "transcoding_reference_frames_description": "Jumlah bingkai untuk direferensikan ketika mengompres bingkai tertentu. Nilai lebih tinggi meningkatkan efisiensi kompresi, tetapi membuat pengodean lambat. 0 menetapkan nilai ini secara otomatis.", "transcoding_required_description": "Hanya video dalam format yang tidak diterima", @@ -304,6 +325,7 @@ "trash_settings_description": "Kelola pengaturan sampah", "untracked_files": "Berkas yang Belum Dilacak", "untracked_files_description": "Berkas ini tidak dilacak oleh aplikasi. Mereka dapat diakibatkan oleh pemindahan gagal, pengunggahan terganggu, atau tertinggal karena oleh kutu", + "user_cleanup_job": "Pembersihan data pengguna", "user_delete_delay": "Akun dan aset {user} akan dijadwalkan untuk penghapusan permanen dalam {delay, plural, one {# hari} other {# hari}}.", "user_delete_delay_settings": "Jeda penghapusan", "user_delete_delay_settings_description": "Jumlah hari setelah penghapusan untuk menghapus akun dan aset pengguna secara permanen. Tugas penghapusan pengguna berjalan pada tengah malam untuk memeriksa pengguna yang siap untuk dihapus. Perubahan pengaturan ini akan dievaluasi pada eksekusi berikutnya.", @@ -317,7 +339,8 @@ "user_settings": "Pengaturan Pengguna", "user_settings_description": "Kelola pengaturan pengguna", "user_successfully_removed": "Pengguna {email} berhasil dikeluarkan.", - "version_check_enabled_description": "Aktifkan permintaan berkala ke GitHub untuk memeriksa rilis baru", + "version_check_enabled_description": "Aktifkan pemeriksaan versi", + "version_check_implications": "Fitur pemeriksaan versi tergantung komunikasi berkala dengan github.com", "version_check_settings": "Pemeriksaan Versi", "version_check_settings_description": "Aktifkan/nonaktifkan notifikasi versi baru", "video_conversion_job": "Transkode video", @@ -333,7 +356,8 @@ "album_added": "Album ditambahkan", "album_added_notification_setting_description": "Terima notifikasi surel ketika Anda ditambahkan ke album terbagi", "album_cover_updated": "Kover album diperbarui", - "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?\nJika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", + "album_delete_confirmation": "Apakah Anda yakin ingin menghapus album {album}?", + "album_delete_confirmation_description": "Jika album ini dibagikan, pengguna lain tidak akan dapat mengaksesnya lagi.", "album_info_updated": "Info album diperbarui", "album_leave": "Tinggalkan album?", "album_leave_confirmation": "Apakah Anda yakin ingin keluar dari {album}?", @@ -357,6 +381,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", + "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.", "api_key_empty": "Nama Kunci API Anda seharusnya jangan kosong", @@ -365,7 +390,7 @@ "appears_in": "Muncul dalam", "archive": "Arsip", "archive_or_unarchive_photo": "Arsipkan atau batalkan pengarsipan foto", - "archive_size": "Ukuran Arsip", + "archive_size": "Ukuran arsip", "archive_size_description": "Atur ukuran arsip untuk unduhan (dalam GiB)", "archived": "", "archived_count": "{count, plural, other {# terarsip}}", @@ -377,9 +402,10 @@ "asset_filename_is_offline": "Aset {filename} sedang luring", "asset_has_unassigned_faces": "Aset memiliki wajah yang belum ditetapkan", "asset_hashing": "Memilah...", - "asset_offline": "Aset luring", - "asset_offline_description": "Aset ini sedang luring. Immich tidak dapat mengakses lokasi berkasnya. Pastikan aset tersebut tersedia lalu pindai ulang pustaka.", + "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...", "assets": "Aset", @@ -391,7 +417,7 @@ "assets_moved_to_trash_count": "Dipindahkan {count, plural, one {# aset} other {# aset}} ke sampah", "assets_permanently_deleted_count": "{count, plural, one {# aset} other {# aset}} dihapus secara permanen", "assets_removed_count": "{count, plural, one {# aset} other {# aset}} dihapus", - "assets_restore_confirmation": "Apakah Anda yakin ingin memulihkan semua aset yang dibuang? Anda tidak dapat mengurungkan tindakan ini!", + "assets_restore_confirmation": "Apakah Anda yakin ingin memulihkan semua aset yang dibuang? Anda tidak dapat mengurungkan tindakan ini! Perlu diingat bahwa aset luring tidak dapat dipulihkan.", "assets_restored_count": "{count, plural, one {# aset} other {# aset}} dipulihkan", "assets_trashed_count": "{count, plural, one {# aset} other {# aset}} dibuang ke sampah", "assets_were_part_of_album_count": "{count, plural, one {Aset telah} other {Aset telah}} menjadi bagian dari album", @@ -402,12 +428,13 @@ "birthdate_saved": "Tanggal lahir berhasil disimpan", "birthdate_set_description": "Tanggal lahir digunakan untuk menghitung umur orang ini pada saat foto diambil.", "blurred_background": "Latar belakang buram", + "bugs_and_feature_requests": "Permintaan Kutu dan Fitur", "build": "Versi", "build_image": "Versi Citra", "bulk_delete_duplicates_confirmation": "Apakah Anda yakin ingin menghapus {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menjaga aset terbesar dari setiap kelompok dan menghapus semua duplikat lain secara permanen. Anda tidak dapat mengurungkan tindakan ini!", "bulk_keep_duplicates_confirmation": "Apakah Anda yakin ingin menyimpan {count, plural, one {# aset duplikat} other {# aset duplikat}}? Ini akan menyelesaikan semua kelompok duplikat tanpa menghapus apa pun.", "bulk_trash_duplicates_confirmation": "Apakah Anda yakin ingin membuang {count, plural, one {# aset duplikat} other {# aset duplikat}} secara bersamaan? Ini akan menyimpan aset terbesar dari setiap kelompok dan membuang semua duplikat lainnya.", - "buy": "Beli Lisensi", + "buy": "Beli Immich", "camera": "Kamera", "camera_brand": "Merek kamera", "camera_model": "Model kamera", @@ -435,11 +462,14 @@ "city": "Kota", "clear": "Hapus", "clear_all": "Hapus semua", + "clear_all_recent_searches": "Hapus semua pencarian terakhir", "clear_message": "Hapus pesan", "clear_value": "Hapus nilai", + "clockwise": "Searah jarum jam", "close": "Tutup", "collapse": "Tutup", "collapse_all": "Tutup Semua", + "color": "Warna", "color_theme": "Tema warna", "comment_deleted": "Komentar dihapus", "comment_options": "Opsi komentar", @@ -473,6 +503,8 @@ "create_new_person": "Buat orang baru", "create_new_person_hint": "Tetapkan aset yang dipilih ke orang yang baru", "create_new_user": "Buat pengguna baru", + "create_tag": "Buat tag", + "create_tag_description": "Buat tag baru. Untuk tag bersarang, harap input jalur tag secara lengkap termasuk tanda garis miring ke depan.", "create_user": "Buat pengguna", "created": "Dibuat", "current_device": "Perangkat saat ini", @@ -493,16 +525,20 @@ "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_key": "Hapus kunci", - "delete_library": "Hapus pustaka", + "delete_library": "Hapus Pustaka", "delete_link": "Hapus tautan", "delete_shared_link": "Hapus tautan terbagi", + "delete_tag": "Hapus tag", + "delete_tag_confirmation_prompt": "Apakah Anda yakin ingin menghapus label tag {tagName}?", "delete_user": "Hapus pengguna", "deleted_shared_link": "Tautan terbagi dihapus", + "deletes_missing_assets": "Menghapus aset yang hilang dari diska", "description": "Deskripsi", "details": "Detail", "direction": "Arah", "disabled": "Dinonaktifkan", "disallow_edits": "Jangan perbolehkan penyuntingan", + "discord": "Discord", "discover": "Jelajahi", "dismiss_all_errors": "Abaikan semua eror", "dismiss_error": "Abaikan eror", @@ -511,8 +547,11 @@ "display_original_photos": "Tampilkan foto asli", "display_original_photos_setting_description": "Lebih suka menampilkan foto ketika menampilkan aset daripada gambar kecil ketika aset asli kompatibel dengan web. Ini dapat mengakibatkan kecepatan pemuatan foto yang lebih lambat.", "do_not_show_again": "Jangan tampilkan pesan ini lagi", + "documentation": "Dokumentasi", "done": "Selesai", "download": "Unduh", + "download_include_embedded_motion_videos": "Video tersematkan", + "download_include_embedded_motion_videos_description": "Sertakan video yg tersematkan dalam foto gerak sebagai file terpisah", "download_settings": "Pengunduhan", "download_settings_description": "Kelola pengaturan berkaitan dengan pengunduhan aset", "downloading": "Mengunduh", @@ -535,10 +574,15 @@ "edit_location": "Sunting lokasi", "edit_name": "Sunting nama", "edit_people": "Sunting orang", + "edit_tag": "Ubah tag", "edit_title": "Sunting Judul", "edit_user": "Sunting pengguna", "edited": "Disunting", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Perubahan tidak akan di simpan", + "editor_close_without_save_title": "Tutup editor?", + "editor_crop_tool_h2_aspect_ratios": "Perbandingan aspek", + "editor_crop_tool_h2_rotation": "Rotasi", "email": "Surel", "empty_trash": "Kosongkan sampah", "empty_trash_confirmation": "Apakah Anda yakin ingin mengosongkan sampah? Ini akan menghapus semua aset dalam sampah secara permanen dari Immich.\nAnda tidak dapat mengurungkan tindakan ini!", @@ -564,6 +608,7 @@ "error_adding_users_to_album": "Terjadi kesalahan menambahkan pengguna ke album", "error_deleting_shared_user": "Terjadi eror menghapus pengguna terbagi", "error_downloading": "Terjadi eror mengunduh {filename}", + "error_hiding_buy_button": "Kesalahan menyembunyikan tombol beli", "error_removing_assets_from_album": "Terjadi eror menghapus aset dari album, lihat konsol untuk detail lebih lanjut", "error_selecting_all_assets": "Terjadi eror memilih semua aset", "exclusion_pattern_already_exists": "Pola pengecualian ini sudah ada.", @@ -574,6 +619,8 @@ "failed_to_get_people": "Gagal mendapatkan orang", "failed_to_load_asset": "Gagal membuka aset", "failed_to_load_assets": "Gagal membuka aset-aset", + "failed_to_load_people": "Gagal mengunggah orang", + "failed_to_remove_product_key": "Gagal menghapus kunci produk", "failed_to_stack_assets": "Gagal menumpuk aset", "failed_to_unstack_assets": "Gagal membatalkan penumpukan aset", "import_path_already_exists": "Jalur pengimporan ini sudah ada.", @@ -621,6 +668,7 @@ "unable_to_get_comments_number": "Tidak bisa mendapatkan jumlah komentar", "unable_to_get_shared_link": "Gagal mendapatkan tautan berbagi", "unable_to_hide_person": "Tidak dapat menyembunyikan orang", + "unable_to_link_motion_video": "Tidak dapat menautkan video gerak", "unable_to_link_oauth_account": "Tidak dapat menautkan akun OAuth", "unable_to_load_album": "Tidak dapat memuat album", "unable_to_load_asset_activity": "Tidak dapat memuat aktivitas aset", @@ -636,8 +684,8 @@ "unable_to_remove_album_users": "Tidak dapat mengeluarkan pengguna dari album", "unable_to_remove_api_key": "Tidak dapat menghapus Kunci API", "unable_to_remove_assets_from_shared_link": "Tidak dapat menghapus aset dari tautan terbagi", + "unable_to_remove_deleted_assets": "Tidak dapat menghapus berkas luring", "unable_to_remove_library": "Tidak dapat menghapus pustaka", - "unable_to_remove_offline_files": "Tidak dapat menghapus berkas luring", "unable_to_remove_partner": "Tidak dapat menghapus partner", "unable_to_remove_reaction": "Tidak dapat menghapus reaksi", "unable_to_repair_items": "Tidak dapat memperbaiki item", @@ -659,6 +707,7 @@ "unable_to_submit_job": "Tidak dapat mengirim tugas", "unable_to_trash_asset": "Tidak dapat membuang aset", "unable_to_unlink_account": "Tidak dapat memutuskan akun", + "unable_to_unlink_motion_video": "Tidak dapat membatalkan tautan video gerak", "unable_to_update_album_cover": "Tidak dapat memperbarui kover album", "unable_to_update_album_info": "Tidak dapat memperbarui info album", "unable_to_update_library": "Tidak dapat memperbarui pustaka", @@ -675,6 +724,7 @@ "expired": "Kedaluwarsa", "expires_date": "Kedaluwarsa pada {date}", "explore": "Jelajahi", + "explorer": "Jelajah", "export": "Ekspor", "export_as_json": "Ekspor sebagai JSON", "extension": "Ekstensi", @@ -686,6 +736,8 @@ "favorite_or_unfavorite_photo": "Favorit atau batalkan pemfavoritan foto", "favorites": "Favorit", "feature_photo_updated": "Foto terfitur diperbarui", + "features": "Fitur", + "features_setting_description": "Kelola fitur aplikasi", "file_name": "Nama berkas", "file_name_or_extension": "Nama berkas atau ekstensi", "filename": "Nama berkas", @@ -693,6 +745,8 @@ "filter_people": "Saring orang", "find_them_fast": "Temukan dengan cepat berdasarkan nama dengan pencarian", "fix_incorrect_match": "Perbaiki pencocokan salah", + "folders": "Berkas", + "folders_feature_description": "Menjelajahi tampilan folder untuk foto dan video pada sistem file", "force_re-scan_library_files": "Paksa Pindai Ulang Semua Berkas Pustaka", "forward": "Maju", "general": "Umum", @@ -716,7 +770,16 @@ "host": "Hos", "hour": "Jam", "image": "Gambar", - "image_alt_text_date": "pada {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} pada tanggal {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} pada {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} pada {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} pada {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1} dan {person2} pada {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {person3} pada {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} diambil di {city}, {country} oleh {person1}, {person2}, dan {additionalCount, number} lainnya pada {date}", "image_alt_text_people": "{count, plural, =1 {dengan {person1}} =2 {dengan {person1} dan {person2}} =3 {dengan {person1}, {person2}, dan {person3}} other {dengan {person1}, {person2}, dan {others, number} lainnya}}", "image_alt_text_place": "di {city}, {country}", "image_taken": "{isVideo, select, true {Video diambil} other {Gambar diambil}}", @@ -781,6 +844,7 @@ "license_trial_info_4": "Pertimbangkan membeli lisensi untuk mendukung keberlanjutan pengembangan layanan", "light": "Terang", "like_deleted": "Suka dihapus", + "link_motion_video": "Tautan video gerak", "link_options": "Opsi tautan", "link_to_oauth": "Tautkan ke OAuth", "linked_oauth_account": "Akun OAuth tertaut", @@ -799,6 +863,7 @@ "look": "Tampilan", "loop_videos": "Ulangi video", "loop_videos_description": "Aktifkan untuk mengulangi video secara otomatis dalam penampil detail.", + "main_branch_warning": "Anda menggunakan versi pengembangan; kami sangat menyarankan menggunakan versi rilis!", "make": "Merek", "manage_shared_links": "Kelola tautan terbagi", "manage_sharing_with_partners": "Kelola pembagian dengan partner", @@ -835,6 +900,7 @@ "name": "Nama", "name_or_nickname": "Nama atau nama panggilan", "never": "Tidak pernah", + "new_album": "Album baru", "new_api_key": "Kunci API Baru", "new_password": "Kata sandi baru", "new_person": "Orang baru", @@ -867,18 +933,21 @@ "notifications": "Notifikasi", "notifications_setting_description": "Kelola notifikasi", "oauth": "OAuth", + "official_immich_resources": "Sumber Daya Immich Resmi", "offline": "Luring", "offline_paths": "Jalur luring", "offline_paths_description": "Hasil berikut dapat diakibatkan oleh penghapusan berkas manual yang bukan bagian dari pustaka eksternal.", "ok": "Oke", "oldest_first": "Terlawas dahulu", "onboarding": "Memulai", + "onboarding_privacy_description": "Fitur berikut (opsional) bergantung pada layanan eksternal, dan dapat dinonaktifkan kapan saja di pengaturan administrasi.", "onboarding_theme_description": "Pilih tema warna untuk server Anda. Ini dapat diubah lagi dalam pengaturan Anda.", "onboarding_welcome_description": "Mari menyiapkan server Anda dengan beberapa pengaturan umum.", "onboarding_welcome_user": "Selamat datang, {user}", "online": "Daring", "only_favorites": "Hanya favorit", "only_refreshes_modified_files": "Hanya menyegarkan berkas yang diubah", + "open_in_map_view": "Buka dalam tampilan peta", "open_in_openstreetmap": "Buka di OpenStreetMap", "open_the_search_filters": "Buka saringan pencarian", "options": "Opsi", @@ -890,7 +959,7 @@ "other_variables": "Variabel lain", "owned": "Dimiliki", "owner": "Pemilik", - "partner": "Partner", + "partner": "Rekan", "partner_can_access": "{partner} dapat mengakses", "partner_can_access_assets": "Semua foto dan video Anda kecuali yang ada di Arsip dan Terhapus", "partner_can_access_location": "Lokasi di mana foto Anda diambil", @@ -913,6 +982,7 @@ "pending": "Tertunda", "people": "Orang", "people_edits_count": "{count, plural, one {# orang} other {# orang}} disunting", + "people_feature_description": "Menjelajahi foto dan video yang dikelompokkan berdasarkan orang", "people_sidebar_description": "Tampilkan tautan ke Orang dalam bilah samping", "permanent_deletion_warning": "Peringatan penghapusan permanen", "permanent_deletion_warning_setting_description": "Tampilkan peringatan ketika menghapus aset secara permanen", @@ -943,10 +1013,47 @@ "previous_memory": "Kenangan sebelumnya", "previous_or_next_photo": "Foto sebelumnya atau berikutnya", "primary": "Utama", + "privacy": "Privasi", "profile_image_of_user": "Foto profil dari {user}", "profile_picture_set": "Foto profil ditetapkan.", "public_album": "Album publik", "public_share": "Pembagian Publik", + "purchase_account_info": "Pendukung", + "purchase_activated_subtitle": "Terima kasih telah mendukung Immich dan perangkat lunak sumber terbuka", + "purchase_activated_time": "Di aktivasi pada {date, date}", + "purchase_activated_title": "Kunci kamu telah sukses di aktivasi", + "purchase_button_activate": "Aktifkan", + "purchase_button_buy": "Beli", + "purchase_button_buy_immich": "Beli Immich", + "purchase_button_never_show_again": "Jangan tampilkan lagi", + "purchase_button_reminder": "Ingatkan saya pada 30 hari lagi", + "purchase_button_remove_key": "Hapus kunci", + "purchase_button_select": "Pilih", + "purchase_failed_activation": "Gagal mengaktifkan! Silakan periksa email kamu untuk kunci produk yang benar!", + "purchase_individual_description_1": "Untuk perorangan", + "purchase_individual_description_2": "Status pendukung", + "purchase_individual_title": "Perorangan", + "purchase_input_suggestion": "Punya kunci produk? Masukkan kunci di bawah ini", + "purchase_license_subtitle": "Beli Immich untuk keberlangsungan pengembangan layanan", + "purchase_lifetime_description": "Pembayaran seumur hidup", + "purchase_option_title": "PILIHAN PEMBAYARAN", + "purchase_panel_info_1": "Membangun Immich membutuhkan banyak waktu dan upaya, dan kami memiliki insinyur penuh waktu yang bekerja untuk membuatnya sebaik mungkin. Misi kami adalah agar perangkat lunak sumber terbuka dan praktik bisnis yang beretika menjadi sumber pendapatan yang berkelanjutan bagi para pengembang dan menciptakan ekosistem yang menghargai privasi dengan alternatif nyata untuk layanan cloud yang eksploitatif.", + "purchase_panel_info_2": "Karena kami berkomitmen untuk tidak menambahkan paywall, pembelian ini tidak akan memberi kamu fitur tambahan apa pun di Immich. Kami mengandalkan pengguna seperti kamu untuk mendukung pengembangan Immich yang sedang berlangsung.", + "purchase_panel_title": "Dukung proyek ini", + "purchase_per_server": "Per server", + "purchase_per_user": "Per pengguna", + "purchase_remove_product_key": "Hapus Kunci Produk", + "purchase_remove_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk?", + "purchase_remove_server_product_key": "Hapus kunci produk Server", + "purchase_remove_server_product_key_prompt": "Apakah kamu yakin ingin menghapus kunci produk Server?", + "purchase_server_description_1": "Untuk keseluruhan server", + "purchase_server_description_2": "Status pendukung", + "purchase_server_title": "Server", + "purchase_settings_server_activated": "Kunci produk server dikelola oleh admin", + "rating": "Peringkat bintang", + "rating_clear": "Hapus peringkat", + "rating_count": "{count, plural, one {# peringkat} other {# peringkat}}", + "rating_description": "Tampilkan peringkat EXIF pada panel info", "reaction_options": "Opsi reaksi", "read_changelog": "Baca Log Perubahan", "reassign": "Tetapkan ulang", @@ -957,11 +1064,13 @@ "recent_searches": "Pencarian terkini", "refresh": "Segarkan", "refresh_encoded_videos": "Segarkan video terenkode", + "refresh_faces": "Segarkan wajah", "refresh_metadata": "Segarkan metadata", "refresh_thumbnails": "Segarkan gambar kecil", "refreshed": "Disegarkan", - "refreshes_every_file": "Menyegarkan setiap berkas", + "refreshes_every_file": "Membaca ulang semua berkas yang sudah ada dan yang baru", "refreshing_encoded_video": "Menyegarkan video terenkode", + "refreshing_faces": "Menyegarkan wajah", "refreshing_metadata": "Menyegarkan metadata", "regenerating_thumbnails": "Membuat ulang gambar kecil", "remove": "Hapus", @@ -969,15 +1078,16 @@ "remove_assets_shared_link_confirmation": "Apakah Anda yakin ingin menghapus {count, plural, one {# aset} other {# aset}} dari tautan terbagi ini?", "remove_assets_title": "Hapus aset?", "remove_custom_date_range": "Hapus jangka tanggal khusus", + "remove_deleted_assets": "Hapus Berkas Luring", "remove_from_album": "Hapus dari album", "remove_from_favorites": "Hapus dari favorit", "remove_from_shared_link": "Hapus dari tautan terbagi", - "remove_offline_files": "Hapus Berkas Luring", "remove_user": "Keluarkan pengguna", "removed_api_key": "Kunci API Dihapus: {name}", "removed_from_archive": "Dihapus dari arsip", "removed_from_favorites": "Dihapus dari favorit", "removed_from_favorites_count": "{count, plural, other {Menghapus #}} dari favorit", + "removed_tagged_assets": "Hapus tag dari {count, plural, one {# aset} other {# aset}}", "rename": "Ubah nama", "repair": "Perbaiki", "repair_no_results_message": "Berkas yang tidak dilacak dan hilang akan muncul di sini", @@ -989,6 +1099,7 @@ "reset_password": "Atur ulang kata sandi", "reset_people_visibility": "Atur ulang keterlihatan orang", "reset_to_default": "Atur ulang ke bawaan", + "resolve_duplicates": "Mengatasi duplikat", "resolved_all_duplicates": "Semua duplikat terselesaikan", "restore": "Pulihkan", "restore_all": "Pulihkan semua", @@ -1007,6 +1118,7 @@ "say_something": "Ucapkan sesuatu", "scan_all_libraries": "Pindai Semua Pustaka", "scan_all_library_files": "Pindai Ulang Semua Berkas Pustaka", + "scan_library": "Pindai", "scan_new_library_files": "Pindai Berkas Pustaka Baru", "scan_settings": "Pengaturan Pemindaian", "scanning_for_album": "Memindai album...", @@ -1022,9 +1134,12 @@ "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_settings": "Pengaturan pencarian", "search_state": "Cari negara bagian...", + "search_tags": "Cari tag...", "search_timezone": "Cari zona waktu...", "search_type": "Jenis pencarian", "search_your_photos": "Cari foto Anda", @@ -1033,6 +1148,7 @@ "see_all_people": "Lihat semua orang", "select_album_cover": "Pilih kover album", "select_all": "Pilih semua", + "select_all_duplicates": "Pilih semua duplikat", "select_avatar_color": "Pilih warna avatar", "select_face": "Pilih wajah", "select_featured_photo": "Pilih foto terfitur", @@ -1065,6 +1181,7 @@ "shared_by_user": "Dibagikan oleh {user}", "shared_by_you": "Dibagikan oleh Anda", "shared_from_partner": "Foto dari {partner}", + "shared_link_options": "Pilihan tautan bersama", "shared_links": "Tautan terbagi", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video terbagi.}}", "shared_with_partner": "Dibagikan dengan {partner}", @@ -1073,6 +1190,7 @@ "sharing_sidebar_description": "Tampilkan tautan ke Pembagian dalam bilah samping", "shift_to_permanent_delete": "tekan ⇧ untuk menghapus aset secara permanen", "show_album_options": "Tampilkan opsi album", + "show_albums": "Tampilkan album", "show_all_people": "Tampilkan semua orang", "show_and_hide_people": "Tampilkan & sembunyikan orang", "show_file_location": "Tampilkan lokasi berkas", @@ -1087,11 +1205,18 @@ "show_person_options": "Tampilkan opsi orang", "show_progress_bar": "Tampilkan Bilah Progres", "show_search_options": "Tampilkan opsi pencarian", + "show_slideshow_transition": "Tampilkan transisi salindia", + "show_supporter_badge": "Lencana suporter", + "show_supporter_badge_description": "Tampilkan lencana suporter", "shuffle": "Acak", + "sidebar": "Bilah sisi", + "sidebar_display_description": "Menampilkan tautan ke tampilan di bilah sisi", "sign_out": "Keluar", "sign_up": "Daftar", "size": "Ukuran", "skip_to_content": "Lewati ke konten", + "skip_to_folders": "Lewati ke berkas", + "skip_to_tags": "Lewati ke tag", "slideshow": "Salindia", "slideshow_settings": "Pengaturan salindia", "sort_albums_by": "Urutkan album berdasarkan...", @@ -1103,6 +1228,8 @@ "sort_title": "Judul", "source": "Sumber", "stack": "Tumpukan", + "stack_duplicates": "Stack duplikat", + "stack_select_one_photo": "Pilih satu foto utama untuk stack", "stack_selected_photos": "Tumpuk foto terpilih", "stacked_assets_count": "{count, plural, one {# aset} other {# aset}} ditumpuk", "stacktrace": "Jejak tumpukan", @@ -1120,27 +1247,41 @@ "submit": "Kirim", "suggestions": "Saran", "sunrise_on_the_beach": "Matahari terbit di pantai", + "support": "Dukungan", + "support_and_feedback": "Dukungan & Masukan", + "support_third_party_description": "Pemasangan Immich Anda telah dipaketkan oleh pihak ketiga. Masalah yang Anda alami dapat disebabkan oleh paket tersebut, jadi silakan ajukan isu dengan masalah tersebut menggunakan tautan di bawah.", "swap_merge_direction": "Ganti arah penggabungan", "sync": "Sinkronisasikan", + "tag": "Tag", + "tag_assets": "Tag aset", + "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_updated": "Tag yang diperbarui: {tag}", + "tagged_assets": "Ditandai {count, plural, one {# aset} other {# aset}}", + "tags": "Tag", "template": "Templat", "theme": "Tema", "theme_selection": "Pemilihan tema", "theme_selection_description": "Tetapkan tema ke terang atau gelap secara otomatis berdasarkan preferensi sistem peramban Anda", "they_will_be_merged_together": "Mereka akan digabungkan bersama", + "third_party_resources": "Sumber Daya Pihak Ketiga", "time_based_memories": "Kenangan berbasis waktu", "timezone": "Zona waktu", "to_archive": "Arsipkan", "to_change_password": "Ubah kata sandi", "to_favorite": "Favorit", "to_login": "Log masuk", + "to_parent": "Ke induk", + "to_root": "Untuk melakukan root", "to_trash": "Sampah", "toggle_settings": "Saklar pengaturan", - "toggle_theme": "Saklar tema", + "toggle_theme": "Beralih tema gelap", "toggle_visibility": "Saklar keterlihatan", "total_usage": "Jumlah penggunaan", "trash": "Sampah", "trash_all": "Buang Semua", - "trash_count": "Buang {count}", + "trash_count": "Sampah {count, number}", "trash_delete_asset": "Hapus Aset", "trash_no_results_message": "Foto dan video di sampah akan muncul di sini.", "trashed_items_will_be_permanently_deleted_after": "Item yang dibuang akan dihapus secara permanen setelah {days, plural, one {# hari} other {# hari}}.", @@ -1153,12 +1294,15 @@ "unknown": "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", "unnamed_album": "Album Tanpa Nama", + "unnamed_album_delete_confirmation": "Apakah kamu yakin akan menghapus album ini?", "unnamed_share": "Pembagian Tanpa Nama", "unsaved_change": "Perubahan belum disimpan", "unselect_all": "Batalkan semua pilihan", + "unselect_all_duplicates": "Batal pilih semua duplikat", "unstack": "Batalkan penumpukan", "unstacked_assets_count": "Penumpukan {count, plural, one {# aset} other {# aset}} dibatalkan", "untracked_files": "Berkas tidak dilacak", @@ -1168,7 +1312,7 @@ "upload": "Unggah", "upload_concurrency": "Konkurensi pengunggahan", "upload_errors": "Unggahan selesai dengan {count, plural, one {# eror} other {# eror}}, muat ulang laman untuk melihat aset terunggah baru.", - "upload_progress": "Tersisa {remaining} - Memproses {processed}/{total}", + "upload_progress": "Tersisa {remaining, number} - Di proses {processed, number}/{total, number}", "upload_skipped_duplicates": "Melewati {count, plural, one {# aset duplikat} other {# aset duplikat}}", "upload_status_duplicates": "Duplikat", "upload_status_errors": "Eror", @@ -1181,7 +1325,9 @@ "user_id": "ID Pengguna", "user_license_settings": "Lisensi", "user_license_settings_description": "Kelola lisensi Anda", - "user_liked": "{user} menyukai {type, select, photo {foto ini} video {video ini} asset {aset ini} other {ini}}", + "user_liked": "{user} menyukai {type, select, photo {foto ini} video {tayangan ini} asset {aset ini} other {ini}}", + "user_purchase_settings": "Pembelian", + "user_purchase_settings_description": "Atur pembelian kamu", "user_role_set": "Tetapkan {user} sebagai {role}", "user_usage_detail": "Detail penggunaan pengguna", "username": "Nama pengguna", @@ -1192,6 +1338,8 @@ "version": "Versi", "version_announcement_closing": "Temanmu, Alex", "version_announcement_message": "Halo, ada versi aplikasi yang baru. Silakan luangkan waktu Anda untuk mengunjungi catatan rilis dan pastikan pengaturan docker-compose.yml dan .env Anda sudah terkini untuk menghindari kesalahan dalam pengaturan, terutama jika Anda menggunakan WatchTower atau mekanisme lain yang menangani pembaruan aplikasi Anda secara otomatis.", + "version_history": "Riwayat Versi", + "version_history_item": "Terpasang {version} pada {date}", "video": "Video", "video_hover_setting": "Putar gambar kecil video saat kursor di atas", "video_hover_setting_description": "Putar gambar kecil video ketika tetikus berada di atas item. Bahkan saat dinonaktifkan, pemutaran dapat dimulai dengan mengambang di atas ikon putar.", @@ -1201,6 +1349,7 @@ "view_album": "Tampilkan Album", "view_all": "Tampilkan Semua", "view_all_users": "Tampilkan semua pengguna", + "view_in_timeline": "Lihat di timeline", "view_links": "Tampilkan tautan", "view_next_asset": "Tampilkan aset berikutnya", "view_previous_asset": "Tampilkan aset sebelumnya", @@ -1211,7 +1360,7 @@ "warning": "Peringatan", "week": "Pekan", "welcome": "Selamat datang", - "welcome_to_immich": "Selamat datang di immich", + "welcome_to_immich": "Selamat datang di Immich", "year": "Tahun", "years_ago": "{years, plural, one {# tahun} other {# tahun}} yang lalu", "yes": "Ya", diff --git a/web/src/lib/i18n/it.json b/i18n/it.json similarity index 83% rename from web/src/lib/i18n/it.json rename to i18n/it.json index cac78ad32b..fc3e9d97fc 100644 --- a/web/src/lib/i18n/it.json +++ b/i18n/it.json @@ -1,8 +1,8 @@ { "about": "Informazioni", - "account": "Account", + "account": "Profilo", "account_settings": "Impostazioni Account", - "acknowledge": "Ho capito", + "acknowledge": "Acconsento", "action": "Azione", "actions": "Azioni", "active": "Attivi", @@ -28,6 +28,7 @@ "added_to_favorites_count": "Aggiunti {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", "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.", @@ -41,6 +42,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", "crontab_guru": "Crontab Guru", "disable_login": "Disabilita login", "disabled": "Disattivato", @@ -49,41 +51,51 @@ "external_library_created_at": "Libreria esterna (creata il {date})", "external_library_management": "Gestione Librerie Esterne", "face_detection": "Rilevamento Volti", - "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Tutto\" (ri-)processerà tutti gli assets. \"Mancanti\" selaziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", - "facial_recognition_job_description": "Raggruppa i volti rilevati in persone. Questo processo viene eseguito dopo che il rilevamento volti è stato completato. \"Tutti\" (ri-)unisce tutti i volti. \"Mancanti\" processa i volti che non hanno una persona assegnata.", + "face_detection_description": "Rileva i volti presenti negli assets utilizzando il machine learning. Per i video, viene presa in considerazione solo la miniatura. \"Aggiorna\" (ri-)processerà tutti gli assets. \"Reset\" inoltre elimina tutti i dati dei volti correnti. \"Mancanti\" seleziona solo gli assets che non sono ancora stati processati. I volti rilevati verranno selezionati per il riconoscimento facciale dopo che il rilevamento dei volti sarà stato completato, raggruppandoli in persone esistenti e/o nuove.", + "facial_recognition_job_description": "Raggruppa i volti rilevati in persone. Questo processo viene eseguito dopo che il rilevamento volti è stato completato. \"Reset\" (ri-)unisce tutti i volti. \"Mancanti\" processa i volti che non hanno una persona assegnata.", "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_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.", "image_prefer_wide_gamut": "Preferisci gamut più ampio", "image_prefer_wide_gamut_setting_description": "Usa lo spazio colore Display P3 per le anteprime. Questo aiuta a mantenere la vivacità delle immagini con spazi colore più ampi, tuttavia potrebbe non mostrare correttamente le immagini con dispositivi e browser obsoleti. Le immagini sRGB vengono preservate per evitare alterazioni del colore.", + "image_preview_description": "Immagine di medie dimensioni con metadati eliminati, utilizzata durante la visualizzazione di una singola risorsa e per l'apprendimento automatico", "image_preview_format": "Formato anteprima", + "image_preview_quality_description": "Qualità dell'anteprima da 1 a 100. Elevata è migliore ma produce file più pesanti e può ridurre la reattività dell'app. Impostare un valore basso può influenzare negativamente la qualità del machine learning.", "image_preview_resolution": "Risoluzione anteprima", "image_preview_resolution_description": "Usata per visualizzazione individuale di foto e per machine learning. Risoluzioni più alte possono preservare più dettagli ma richiedono un encoding più lento, occupano più spazio, e possono ridurre la responsività della app.", + "image_preview_title": "Impostazioni dell'anteprima", "image_quality": "Qualità", "image_quality_description": "Qualità dell'immagine da 1 a 100. Un valore più alto risulta in una migliore qualità, ma produce file più grandi.", + "image_resolution": "Risoluzione", + "image_resolution_description": "Risoluzioni più elevate possono preservare più dettagli ma richiedere più tempo per la codifica, avere dimensioni di file più grandi e possono ridurre la reattività dell'app.", "image_settings": "Impostazioni delle immagini", "image_settings_description": "Gestisci qualità e risoluzione delle immagini generate", + "image_thumbnail_description": "Miniatura piccola senza metadati, utilizzata durante la visualizzazione di gruppi di foto come la sequenza temporale principale", "image_thumbnail_format": "Formato miniatura", + "image_thumbnail_quality_description": "Qualità delle miniature da 1 a 100. Un valore più alto è migliore, ma produce file più grandi e può ridurre la reattività dell'app.", "image_thumbnail_resolution": "Risoluzione miniatura", - "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale,vista album, etc.). Risoluzioni piu' alte possono mantenere piu' dettaglio pero' l'encoding sara' piu' lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsivita' dell'applicazione.", + "image_thumbnail_resolution_description": "Utilizzato per vedere gruppi di foto (linea temporale, vista album, etc.). Risoluzioni più alte possono mantenere più dettaglio però l'encoding sarà più lungo, i file avranno dimensioni maggiori e potrebbero causare una riduzione nella responsività dell'applicazione.", + "image_thumbnail_title": "Impostazioni della copertina", "job_concurrency": "Concorrenza {job}", + "job_created": "Lavoro creato", "job_not_concurrency_safe": "Questo processo non è eseguibile in maniera concorrente.", "job_settings": "Impostazioni dei processi", "job_settings_description": "Gestisci la concorrenza dei processi", "job_status": "Stato Processi", "jobs_delayed": "{jobCount, plural, one {# posticipato} other {# posticipati}}", "jobs_failed": "{jobCount, plural, one {# fallito} other {# falliti}}", - "library_created": "Creata libreria {library}", + "library_created": "Creata libreria: {library}", "library_cron_expression": "Espressione cron", "library_cron_expression_description": "Imposta l'intervallo di rilevazione utilizzando il formato cron. Per più informazioni consulta es. Crontab Guru", "library_cron_expression_presets": "Espressioni cron preimpostate", "library_deleted": "Libreria eliminata", "library_import_path_description": "Specifica una cartella da importare. Questa cartella e le sue sottocartelle, verranno analizzate per cercare immagini e video.", "library_scanning": "Scansione periodica", - "library_scanning_description": "Conigura la scansione periodica della libreria", + "library_scanning_description": "Configura la scansione periodica della libreria", "library_scanning_enable_description": "Attiva la scansione periodica della libreria", "library_settings": "Libreria Esterna", "library_settings_description": "Gestisci le impostazioni della libreria esterna", @@ -95,7 +107,7 @@ "logging_level_description": "Quando attivato, che livello di log utilizzare.", "logging_settings": "Registro dei Log", "machine_learning_clip_model": "Modello CLIP", - "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Bita cge devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", + "machine_learning_clip_model_description": "Il nome del modello CLIP mostrato qui. Nota che devi rieseguire il processo 'Ricerca Intelligente' per tutte le immagini al cambio del modello.", "machine_learning_duplicate_detection": "Rilevamento Duplicati", "machine_learning_duplicate_detection_enabled": "Attiva rilevazione duplicati", "machine_learning_duplicate_detection_enabled_description": "Se disattivo, risorse perfettamente identiche saranno comunque deduplicate.", @@ -103,16 +115,16 @@ "machine_learning_enabled": "Attiva machine learning", "machine_learning_enabled_description": "Se disabilitato, tutte le funzioni di ML saranno disabilitate ignorando le importazioni sottostanti.", "machine_learning_facial_recognition": "Riconoscimento Facciale", - "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa faccie nelle immagini", + "machine_learning_facial_recognition_description": "Rileva, riconosci, e raggruppa facce nelle immagini", "machine_learning_facial_recognition_model": "Modello di riconoscimento facciale", - "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, peró producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", + "machine_learning_facial_recognition_model_description": "I modelli sono mostrati in ordine decrescente in base alla dimensione. I modelli più grandi sono più lenti e utilizzano più memoria, però producono risultati migliori. Nota che devi ri-eseguire il processo di rilevamento facciale per tutte le immagini quando cambi il modello.", "machine_learning_facial_recognition_setting": "Attiva riconoscimento facciale", - "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagininon non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", + "machine_learning_facial_recognition_setting_description": "Se disabilitato, le immagini non saranno codificate per il riconoscimento facciale e non verranno mostrate nella sezione Persone della pagina Esplora.", "machine_learning_max_detection_distance": "Distanza massima di rilevazione", - "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare risultati fasulli.", + "machine_learning_max_detection_distance_description": "Massima distanza fra due immagini per considerarle duplicate, variando da 0.001-0.1. Valori più alti rileveranno più duplicati, ma potrebbero causare falsi positivi.", "machine_learning_max_recognition_distance": "Distanza massima di riconoscimento", "machine_learning_max_recognition_distance_description": "La distanza massima tra due volti per essere considerati la stessa persona, che varia da 0 a 2. Abbassare questo valore può prevenire l'etichettatura di due persone come se fossero la stessa persona, mentre aumentarlo può prevenire l'etichettatura della stessa persona come se fossero due persone diverse. Nota che è più facile unire due persone che separare una persona in due, quindi è preferibile mantenere una soglia più bassa quando possibile.", - "machine_learning_min_detection_score": "Punteggio minimo di rilvazione", + "machine_learning_min_detection_score": "Punteggio minimo di rilevazione", "machine_learning_min_detection_score_description": "Punteggio di confidenza minimo per rilevare un volto, da 0 a 1. Valori più bassi rileveranno più volti, ma potrebbero generare risultati fasulli.", "machine_learning_min_recognized_faces": "Minimo volti rilevati", "machine_learning_min_recognized_faces_description": "Il numero minimo di volti riconosciuti per creare una persona. Aumentando questo valore si rende il riconoscimento facciale più preciso, ma aumenta la possibilità che un volto non venga assegnato a una persona.", @@ -129,16 +141,21 @@ "map_enable_description": "Abilita funzionalità della mappa", "map_gps_settings": "Impostazioni Mappe & GPS", "map_gps_settings_description": "Gestisci le impostazioni di Mappe & GPS (Geocoding Inverso)", + "map_implications": "La funzionalità mappa si basa su un servizio tile esterno (tiles.immich.cloud)", "map_light_style": "Tema chiaro", "map_manage_reverse_geocoding_settings": "Gestisci impostazioni Geocodifica inversa", "map_reverse_geocoding": "Geocodifica inversa", "map_reverse_geocoding_enable_description": "Abilita geocodifica inversa", "map_reverse_geocoding_settings": "Impostazioni Geocodifica Inversa", - "map_settings": "Impostazioni Mappa e GPS", + "map_settings": "Impostazioni Mappa e Posizione", "map_settings_description": "Gestisci impostazioni mappa", "map_style_description": "URL per un tema della mappa style.json", "metadata_extraction_job": "Estrazione Metadata", - "metadata_extraction_job_description": "Estrai informazioni dai metadati di ciascun asset, ad esempio coordinate GPS e risoluzione", + "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", + "metadata_faces_import_setting_description": "Importa i volti dai dati EXIF dell'immagine e dai file sidecar", + "metadata_settings": "Impostazioni Metadati", + "metadata_settings_description": "Gestisci le impostazioni dei metadati", "migration_job": "Migrazione", "migration_job_description": "Migra le anteprime per gli asset e volti alla struttura di cartelle più recente", "no_paths_added": "Nessun percorso aggiunto", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "NOTA: Non potrà essere modificato in futuro!", "note_unlimited_quota": "Nota: Inserisci 0 per una quota illimitata", "notification_email_from_address": "Indirizzo mittente", - "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", + "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", "notification_email_host_description": "Host del server email (es. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora errori di certificato", "notification_email_ignore_certificate_errors_description": "Ignora errori di validazione del certificato TLS (sconsigliato)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL emittente", "oauth_mobile_redirect_uri": "URI reindirizzamento mobile", "oauth_mobile_redirect_uri_override": "Sovrascrivi URI reindirizzamento cellulare", - "oauth_mobile_redirect_uri_override_description": "Abilita quando 'app.immich:/' non è un URI di reindirizzamento valido.", + "oauth_mobile_redirect_uri_override_description": "Abilita quando il gestore OAuth non consente un URL come '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo firma profilo", "oauth_profile_signing_algorithm_description": "L'algoritmo usato per firmare il profilo utente.", "oauth_scope": "Ambito di autorizzazione", @@ -193,19 +210,22 @@ "password_settings": "Login con password", "password_settings_description": "Gestisci impostazioni del login con password", "paths_validated_successfully": "Percorsi validati con successo", + "person_cleanup_job": "Pulizia Persona", "quota_size_gib": "Dimensione Archiviazione (GiB)", "refreshing_all_libraries": "Aggiorna tutte le librerie", "registration": "Registrazione amministratore", "registration_description": "Poiché sei il primo utente del sistema, sarai assegnato come Amministratore e sarai responsabile dei task amministrativi, e utenti aggiuntivi saranno creati da te.", - "removing_offline_files": "Cancella File Offline", + "removing_deleted_files": "Cancella File Offline", "repair_all": "Ripara Tutto", "repair_matched_items": "{count, plural, one {Rilevato # elemento} other {Rilevati # elementi}}", "repaired_items": "{count, plural, one {Riparato # elemento} other {Riparati # elementi}}", "require_password_change_on_login": "Richiedi all'utente di cambiare password al primo accesso", "reset_settings_to_default": "Ripristina impostazioni predefinite", "reset_settings_to_recent_saved": "Ripristina impostazioni alle impostazioni salvate di recente", + "scanning_library": "Scansione della libreria", "scanning_library_for_changed_files": "Scansiona la libreria per file modificati", "scanning_library_for_new_files": "Scansiona la libreria per nuovi file", + "search_jobs": "Cerca Jobs...", "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)://", @@ -224,7 +244,7 @@ "storage_template_hash_verification_enabled_description": "Attiva verifica hash, non disabilitare questo se non sei certo delle implicazioni", "storage_template_migration": "Migrazione modello archiviazione", "storage_template_migration_description": "Applica il {template} attuale agli asset caricati in precedenza", - "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifice retroattivamente esegui {job}.", + "storage_template_migration_info": "Le modifiche al modello di archiviazione verranno applicate solo agli asset nuovi. Per applicare le modifiche retroattivamente esegui {job}.", "storage_template_migration_job": "Processo Migrazione Modello di Archiviazione", "storage_template_more_details": "Per più informazioni riguardo a questa funzionalità, consulta il Modello Archiviazione e le sue conseguenze", "storage_template_onboarding_description": "Quando attivata, questa funzionalità organizzerà automaticamente i file utilizzando il modello di archiviazione definito dall'utente. Per ragioni di stabilità, questa funzionalità è disabilitata per impostazione predefinita. Per più informazioni, consulta la documentazione.", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Gestisci la struttura delle cartelle e il nome degli asset caricati", "storage_template_user_label": "{label} è l'etichetta di archiviazione dell'utente", "system_settings": "Impostazioni di sistema", + "tag_cleanup_job": "Pulisci Tag", "theme_custom_css_settings": "CSS Personalizzato", "theme_custom_css_settings_description": "I Cascading Style Sheets (CSS) permettono di personalizzare l'interfaccia di Immich.", "theme_settings": "Impostazioni Tema", @@ -259,26 +280,26 @@ "transcoding_bitrate_description": "Video con bitrate superiore al massimo o in formato non accettato", "transcoding_codecs_learn_more": "Per saperne di più sulla terminologia utilizzata, fai riferimento alla documentazione di FFmpeg su codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modalità qualità costante", - "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, peró alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perchè non supporta ICQ.", + "transcoding_constant_quality_mode_description": "iCQ è migliore di CQP, però alcuni dispositivi di accelerazione hardware non supportano questa modalità. Impostando questa opzione l'applicazione preferirà il modo specificato quando è in uso la codifica quality-based. Ignorato da NVENC perché non supporta ICQ.", "transcoding_constant_rate_factor": "Fattore di rateo costante (-crf)", "transcoding_constant_rate_factor_description": "Livello di qualità video. I valori tipici sono 23 per H.264, 28 per HEVC, 31 per VP9 e 35 per AV1. Un valore inferiore indica una qualità migliore, ma produce file di dimensioni maggiori.", "transcoding_disabled_description": "Non transcodificare alcun video, potrebbe rompere la riproduzione su alcuni client", "transcoding_hardware_acceleration": "Accelerazione Hardware", "transcoding_hardware_acceleration_description": "Sperimentale; molto più veloce, ma avrà una qualità inferiore allo stesso bitrate", "transcoding_hardware_decoding": "Decodifica hardware", - "transcoding_hardware_decoding_setting_description": "Si applica solo a NVENC, QSV e RKMPP. Abilita l'accelerazione end-to-end anziché solo l'accelerazione dell'encoding. Potrebbe non funzionare su tutti i video.", + "transcoding_hardware_decoding_setting_description": "Abilita l'accelerazione end-to-end anziché accelerare solo la codifica. Potrebbe non funzionare su tutti i video.", "transcoding_hevc_codec": "Codec HEVC", "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_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_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", "transcoding_preferred_hardware_device": "Dispositivo hardware preferito", "transcoding_preferred_hardware_device_description": "Si applica solo a VAAPI e QSV. Imposta il nodo DRI utilizzato per la transcodifica hardware.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Velocità di compressione. Preset più lenti producono file più piccoli e aumentano la qualità quando si punta ad ottenere un certo bitrate. VP9 ignora velocità superiori a `faster`.", + "transcoding_preset_preset_description": "Velocità di compressione. Preset più lenti producono file più piccoli e aumentano la qualità quando viene impostato un certo bitrate. VP9 ignora velocità superiori a `faster`.", "transcoding_reference_frames": "Frame di riferimento", "transcoding_reference_frames_description": "Il numero di frame da prendere in considerazione nel comprimere un determinato frame. Valori più alti migliorano l'efficienza di compressione, ma rallentano la codifica. 0 imposta questo valore automaticamente.", "transcoding_required_description": "Solo video che non sono in un formato accettato", @@ -287,7 +308,7 @@ "transcoding_target_resolution": "Risoluzione desiderata", "transcoding_target_resolution_description": "Risoluzioni più elevate possono preservare più dettagli ma richiedono più tempo per la codifica, producono file di dimensioni maggiori e possono ridurre la reattività dell'applicazione.", "transcoding_temporal_aq": "AQ temporale", - "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualita delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", + "transcoding_temporal_aq_description": "Si applica solo a NVENC. Aumenta la qualità delle scene con molto dettaglio e poco movimento. Potrebbe non essere compatibile con dispositivi più vecchi.", "transcoding_threads": "Thread", "transcoding_threads_description": "Valori più alti portano a una codifica più veloce, ma lasciano meno spazio al server per elaborare altre attività durante l'attività. Questo valore non dovrebbe essere superiore al numero di core CPU. Massimizza l'utilizzo se impostato su 0.", "transcoding_tone_mapping": "Mappatura della tonalità", @@ -307,20 +328,22 @@ "trash_settings_description": "Gestisci impostazioni cestino", "untracked_files": "File non tracciati", "untracked_files_description": "Questi file non sono tracciati dall'applicazione. Potrebbero essere il risultato di spostamenti falliti, caricamenti interrotti o abbandonati a causa di un bug", + "user_cleanup_job": "Pulizia Utente", "user_delete_delay": "L'account e gli asset dell'utente {user} verranno programmati per la cancellazione definitiva tra {delay, plural, one {# giorno} other {# giorni}}.", "user_delete_delay_settings": "Ritardo eliminazione", - "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla possima esecuzione.", + "user_delete_delay_settings_description": "Numero di giorni dopo l'eliminazione per cancellare in modo definitivo l'account e gli asset di un utente. Il processo di cancellazione dell'utente viene eseguito a mezzanotte per verificare se esistono utenti pronti a essere eliminati. Le modifiche a questa impostazioni saranno prese in considerazione dalla prossima esecuzione.", "user_delete_immediately": "L'account e tutti gli asset dell'utente {user} verranno messi in coda per la cancellazione permanente immediata.", "user_delete_immediately_checkbox": "utente", "user_management": "Gestione Utenti", "user_password_has_been_reset": "La password dell'utente è stata reimpostata:", "user_password_reset_description": "Per favore inserisci una password temporanea per l'utente e informalo che dovrà cambiare la password al prossimo login.", "user_restore_description": "L'account di {user} verrà ripristinato.", - "user_restore_scheduled_removal": "Ripristina utente - rimozione progammata per il {date, date, long}", + "user_restore_scheduled_removal": "Ripristina utente - rimozione programmata per il {date, date, long}", "user_settings": "Impostazione Utente", "user_settings_description": "Gestisci impostazioni utente", "user_successfully_removed": "L'utente {email} è stato rimosso con successo.", - "version_check_enabled_description": "Abilita richieste periodiche a Github per verificare se esistono nuove versioni", + "version_check_enabled_description": "Abilita controllo della versione", + "version_check_implications": "La funzione di controllo della versione fa uso di una comunicazione periodica con github.com", "version_check_settings": "Controllo Versione", "version_check_settings_description": "Abilita/disabilita la notifica per nuove versioni", "video_conversion_job": "Trascodifica video", @@ -336,7 +359,8 @@ "album_added": "Album aggiunto", "album_added_notification_setting_description": "Ricevi una notifica email quando sei aggiunto a un album condiviso", "album_cover_updated": "Copertina dell'album aggiornata", - "album_delete_confirmation": "Sei sicuro di voler cancellare l'album {album}?\nSe l'album è stato condiviso, gli altri utenti non potranno più accedervi.", + "album_delete_confirmation": "Sei sicuro di voler cancellare l'album {album}?", + "album_delete_confirmation_description": "Se l'album è condiviso gli altri utenti perderanno l'accesso.", "album_info_updated": "Informazioni dell'album aggiornate", "album_leave": "Abbandona l'album?", "album_leave_confirmation": "Sei sicuro di voler abbandonare {album}?", @@ -356,13 +380,14 @@ "all_albums": "Tutti gli album", "all_people": "Tutte le persone", "all_videos": "Tutti i video", - "allow_dark_mode": "Permetti tema scuro", - "allow_edits": "Permetti modifiche", - "allow_public_user_to_download": "Permetti di scaricare agli utenti pubblici", - "allow_public_user_to_upload": "Permetti di caricare agli utenti pubblici", + "allow_dark_mode": "Permetti Tema Scuro", + "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", "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 valore del nome dell'API Key non può essere vuoto", + "api_key_empty": "Il Nome dell'API Key non può essere vuoto", "api_keys": "Chiavi API", "app_settings": "Impostazioni Applicazione", "appears_in": "Compare in", @@ -379,10 +404,11 @@ "asset_description_updated": "La descrizione del media non è stata aggiornata", "asset_filename_is_offline": "Il media {filename} è offline", "asset_has_unassigned_faces": "Il media ha dei volti non categorizzati", - "asset_hashing": "Hashing...", - "asset_offline": "Risorsa offline", - "asset_offline_description": "Il media è offline. Immich non è in grado di accedere al percorso del file. Assicurarsi che il media sia disponibile e riscansionare la libreria.", + "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_uploaded": "Caricato", "asset_uploading": "Caricamento...", "assets": "Risorse", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "{count, plural, one {# asset spostato} other {# asset spostati}} nel cestino", "assets_permanently_deleted_count": "{count, plural, one {# asset cancellato} other {# asset cancellati}} definitivamente", "assets_removed_count": "{count, plural, one {# asset rimosso} other {# asset rimossi}}", - "assets_restore_confirmation": "Sei sicuro di voler ripristinare tutti gli asset cancellati? Non puoi annullare questa azione!", + "assets_restore_confirmation": "Sei sicuro di voler ripristinare tutti gli asset cancellati? Non puoi annullare questa azione! Tieni presente che eventuali risorse offline NON possono essere ripristinate in questo modo.", "assets_restored_count": "{count, plural, one {# asset ripristinato} other {# asset ripristinati}}", "assets_trashed_count": "{count, plural, one {Spostato # asset} other {Spostati # assets}} nel cestino", "assets_were_part_of_album_count": "{count, plural, one {L'asset era} other {Gli asset erano}} già parte dell'album", @@ -405,6 +431,7 @@ "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.", "blurred_background": "Sfondo sfocato", + "bugs_and_feature_requests": "Bug & Richieste di nuove funzionalità", "build": "Compilazione", "build_image": "Compila Immagine", "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!", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Rimuovi tutte le ricerche recenti", "clear_message": "Pulisci messaggio", "clear_value": "Pulisci valore", + "clockwise": "Senso orario", "close": "Chiudi", "collapse": "Restringi", "collapse_all": "Comprimi tutto", + "color": "Colore", "color_theme": "Colore Tema", "comment_deleted": "Commento eliminato", "comment_options": "Opzioni per i commenti", @@ -453,7 +482,7 @@ "confirm_admin_password": "Conferma password amministratore", "confirm_delete_shared_link": "Sei sicuro di voler eliminare questo link condiviso?", "confirm_password": "Conferma password", - "contain": "Contieni", + "contain": "Adatta", "context": "Contesto", "continue": "Continua", "copied_image_to_clipboard": "Immagine copiata negli appunti.", @@ -466,7 +495,7 @@ "copy_password": "Copia password", "copy_to_clipboard": "Copia negli appunti", "country": "Nazione", - "cover": "Copri", + "cover": "Riempi", "covers": "Miniature", "create": "Crea", "create_album": "Crea album", @@ -477,6 +506,8 @@ "create_new_person": "Crea nuova persona", "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_user": "Crea utente", "created": "Creato", "current_device": "Dispositivo corrente", @@ -497,30 +528,37 @@ "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_key": "Elimina chiave", - "delete_library": "Elimina libreria", + "delete_library": "Elimina Libreria", "delete_link": "Elimina link", "delete_shared_link": "Elimina link condiviso", + "delete_tag": "Elimina tag", + "delete_tag_confirmation_prompt": "Sei sicuro di voler cancellare il tag {tagName}?", "delete_user": "Elimina utente", "deleted_shared_link": "Elimina link condiviso", + "deletes_missing_assets": "Cancella gli asset mancanti dal disco", "description": "Descrizione", "details": "Dettagli", "direction": "Direzione", "disabled": "Disabilitato", "disallow_edits": "Blocca modifiche", + "discord": "Discord", "discover": "Scopri", "dismiss_all_errors": "Ignora tutti gli errori", "dismiss_error": "Ignora errore", "display_options": "Impostazioni visualizzazione", - "display_order": "Ordine visualizzazione", + "display_order": "Ordine di visualizzazione", "display_original_photos": "Visualizza foto originali", "display_original_photos_setting_description": "Visualizza la foto originale anziché le miniature quando l'asset originale è compatibile con il web. Questo potrebbe causare un ritardo nella visualizzazione delle foto.", - "do_not_show_again": "Non mostrare questo messaggio di nuovo", + "do_not_show_again": "Non mostrare più questo messaggio", + "documentation": "Documentazione", "done": "Fatto", "download": "Scarica", + "download_include_embedded_motion_videos": "Video incorporati", + "download_include_embedded_motion_videos_description": "Includere i video incorporati nelle foto in movimento come file separato", "download_settings": "Scarica", - "download_settings_description": "Gestisci le impostazioni riguardandi il download degli asset", + "download_settings_description": "Gestisci le impostazioni relative al download delle risorse", "downloading": "Scaricando", - "downloading_asset_filename": "Scaricando l'asset {filename}", + "downloading_asset_filename": "Scaricando la risorsa {filename}", "drop_files_to_upload": "Rilascia i file ovunque per caricarli", "duplicates": "Duplicati", "duplicates_description": "Risolvi ciascun gruppo indicando quali sono, se esistono, i duplicati", @@ -546,15 +584,20 @@ "edit_location": "Modifica posizione", "edit_name": "Modifica nome", "edit_people": "Modifica persone", + "edit_tag": "Modifica tag", "edit_title": "Modifica Titolo", "edit_user": "Modifica utente", "edited": "Modificato", "editor": "Editor", + "editor_close_without_save_prompt": "Le modifiche non verranno salvate", + "editor_close_without_save_title": "Vuoi chiudere l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporzioni", + "editor_crop_tool_h2_rotation": "Rotazione", "email": "Email", "empty": "", "empty_album": "Album Vuoto", "empty_trash": "Svuota cestino", - "empty_trash_confirmation": "Sei sicuro di volere svuotare il cestino? Questo rimuoverà tutti gli asset nel cestino in modo permanente da Immich.\nNon puoi annullare questa azione!", + "empty_trash_confirmation": "Sei sicuro di volere svuotare il cestino? Questo rimuoverà tutte le risorse nel cestino in modo permanente da Immich.\nNon puoi annullare questa azione!", "enable": "Abilita", "enabled": "Abilitato", "end_date": "Data Fine", @@ -562,8 +605,8 @@ "error_loading_image": "Errore nel caricamento dell'immagine", "error_title": "Errore - Qualcosa è andato storto", "errors": { - "cannot_navigate_next_asset": "Impossibile passare all'asset successivo", - "cannot_navigate_previous_asset": "Impossibile passare all'asset precedente", + "cannot_navigate_next_asset": "Impossibile passare alla risorsa successiva", + "cannot_navigate_previous_asset": "Impossibile passare alla risorsa precedente", "cant_apply_changes": "Impossibile applicare le modifiche", "cant_change_activity": "Impossibile {enabled, select, true {disabilitare} other {abilitare}} l'attività", "cant_change_asset_favorite": "Impossibile cambiare il preferito per l'asset", @@ -571,24 +614,24 @@ "cant_get_faces": "Impossibile ottenere i volti", "cant_get_number_of_comments": "Impossibile ottenere il numero di commenti", "cant_search_people": "Impossibile cercare persone", - "cant_search_places": "Impossibile cercare posti", - "cleared_jobs": "Puliti i processi per: {job}", - "error_adding_assets_to_album": "Errore aggiungendo gli asset all'album", + "cant_search_places": "Impossibile cercare luoghi", + "cleared_jobs": "Eliminati i processi per: {job}", + "error_adding_assets_to_album": "Errore aggiungendo le risorse all'album", "error_adding_users_to_album": "Errore aggiungendo gli utenti all'album", "error_deleting_shared_user": "Errore durante la cancellazione dell'utente condiviso", "error_downloading": "Errore scaricando {filename}", "error_hiding_buy_button": "Errore nel nascondere il pulsante di acquisto", - "error_removing_assets_from_album": "Errore rimuovendo gli asset dall'album, controlla la console per ulteriori dettagli", - "error_selecting_all_assets": "Errore selezionando tutti gli asset", - "exclusion_pattern_already_exists": "Questo pattern di esclusione già esiste.", + "error_removing_assets_from_album": "Errore rimuovendo le risorse dall'album, controlla la console per ulteriori dettagli", + "error_selecting_all_assets": "Errore selezionando tutte le risorse", + "exclusion_pattern_already_exists": "Questo pattern di esclusione è già presente.", "failed_job_command": "Il comando {command} è fallito per il processo: {job}", "failed_to_create_album": "Creazione dell'album non riuscita", - "failed_to_create_shared_link": "Creazione del link condiviso non riuscita", - "failed_to_edit_shared_link": "Errore durante la modifica del link condiviso", + "failed_to_create_shared_link": "Creazione del link condivisibile non riuscita", + "failed_to_edit_shared_link": "Errore durante la modifica del link condivisibile", "failed_to_get_people": "Impossibile ottenere le persone", - "failed_to_load_asset": "Errore durante il caricamento dell'asset", - "failed_to_load_assets": "Errore durante il caricamento degli assets", - "failed_to_load_people": "Caricamento delle persone fallito", + "failed_to_load_asset": "Errore durante il caricamento della risorsa", + "failed_to_load_assets": "Errore durante il caricamento delle risorse", + "failed_to_load_people": "Caricamento delle persone non riuscito", "failed_to_remove_product_key": "Rimozione del codice del prodotto fallita", "failed_to_stack_assets": "Errore durante il raggruppamento degli assets", "failed_to_unstack_assets": "Errore durante la separazione degli assets", @@ -639,9 +682,10 @@ "unable_to_get_comments_number": "Impossibile ottenere il numero di commenti", "unable_to_get_shared_link": "Impossibile ottenere il link condiviso", "unable_to_hide_person": "Impossibile nascondere persona", + "unable_to_link_motion_video": "Impossibile collegare video in movimento", "unable_to_link_oauth_account": "Impossibile collegare l'account OAuth", "unable_to_load_album": "Impossibile caricare l'album", - "unable_to_load_asset_activity": "Impossiible caricare l'attività dell'asset", + "unable_to_load_asset_activity": "Impossibile caricare l'attività dell'asset", "unable_to_load_items": "Impossibile caricare gli elementi", "unable_to_load_liked_status": "Impossibile caricare lo stato dei preferiti", "unable_to_log_out_all_devices": "Impossibile eseguire il logout da tutti i dispositivi", @@ -655,13 +699,13 @@ "unable_to_remove_api_key": "Impossibile rimuovere la chiave API", "unable_to_remove_assets_from_shared_link": "Errore durante la rimozione degli assets da un link condiviso", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Impossibile rimuovere i file offline", "unable_to_remove_library": "Impossibile rimuovere libreria", - "unable_to_remove_offline_files": "Impossibile rimuovere i file offline", "unable_to_remove_partner": "Impossibile rimuovere compagno", "unable_to_remove_reaction": "Impossibile rimuovere reazione", "unable_to_remove_user": "", "unable_to_repair_items": "Impossibile riparare elementi", - "unable_to_reset_password": "Impossiible reimpostare la password", + "unable_to_reset_password": "Impossibile reimpostare la password", "unable_to_resolve_duplicate": "Impossibile risolvere duplicato", "unable_to_restore_assets": "Impossibile ripristinare gli asset", "unable_to_restore_trash": "Impossibile ripristinare cestino", @@ -669,23 +713,24 @@ "unable_to_save_album": "Impossibile salvare album", "unable_to_save_api_key": "Impossibile salvare chiave API", "unable_to_save_date_of_birth": "Impossible salvare la data di nascita", - "unable_to_save_name": "Impossibile salvare nome", - "unable_to_save_profile": "Impossibile salvare profilo", - "unable_to_save_settings": "Impossibile salvare impostazioni", - "unable_to_scan_libraries": "Impossibile analizzare librerie", - "unable_to_scan_library": "Impossibile analizzare libreria", + "unable_to_save_name": "Impossibile salvare il nome", + "unable_to_save_profile": "Impossibile salvare il profilo", + "unable_to_save_settings": "Impossibile salvare le impostazioni", + "unable_to_scan_libraries": "Impossibile analizzare le librerie", + "unable_to_scan_library": "Impossibile analizzare la libreria", "unable_to_set_feature_photo": "Impossibile impostare la foto in evidenza", - "unable_to_set_profile_picture": "Impossibile impostare foto profilo", - "unable_to_submit_job": "Impossibile confermare processo", - "unable_to_trash_asset": "Impossibile cestinare asset", - "unable_to_unlink_account": "Impossibile scollegare account", + "unable_to_set_profile_picture": "Impossibile impostare la foto profilo", + "unable_to_submit_job": "Impossibile eseguire l'attività", + "unable_to_trash_asset": "Impossibile cestinare l'asset", + "unable_to_unlink_account": "Impossibile scollegare l'account", + "unable_to_unlink_motion_video": "Impossibile scollegare video in movimento", "unable_to_update_album_cover": "Errore durante l'aggiornamento della copertina dell'album", - "unable_to_update_album_info": "Errore durante l'aggiornamento delle info dell'album", - "unable_to_update_library": "Impossibile aggiornare libreria", - "unable_to_update_location": "Impossibile aggiornare posizione", - "unable_to_update_settings": "Impossibile aggiornare impostazioni", - "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato visivo della linea temporale", - "unable_to_update_user": "Impossibile aggiornare utente", + "unable_to_update_album_info": "Impossibile aggiornare le informazioni sull'album", + "unable_to_update_library": "Impossibile aggiornare la libreria", + "unable_to_update_location": "Impossibile aggiornare la posizione", + "unable_to_update_settings": "Impossibile aggiornare le impostazioni", + "unable_to_update_timeline_display_status": "Impossibile aggiornare lo stato di visualizzazione della sequenza temporale", + "unable_to_update_user": "Impossibile aggiornare l'utente", "unable_to_upload_file": "Impossibile caricare il file" }, "every_day_at_onepm": "", @@ -693,12 +738,13 @@ "every_night_at_twoam": "", "every_six_hours": "", "exif": "Exif", - "exit_slideshow": "Esci dalla diapositiva", + "exit_slideshow": "Esci dalla presentazione", "expand_all": "Espandi tutto", "expire_after": "Scade dopo", "expired": "Scaduto", "expires_date": "Scade il {date}", "explore": "Esplora", + "explorer": "Esplora", "export": "Esporta", "export_as_json": "Esporta come JSON", "extension": "Estensione", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Foto in evidenza aggiornata", "featurecollection": "", + "features": "Funzionalità", + "features_setting_description": "Gestisci le funzionalità dell'app", "file_name": "Nome file", "file_name_or_extension": "Nome file o estensione", "filename": "Nome file", @@ -720,6 +768,8 @@ "filter_people": "Filtra persone", "find_them_fast": "Trovale velocemente con la ricerca", "fix_incorrect_match": "Correggi corrispondenza errata", + "folders": "Cartelle", + "folders_feature_description": "Navigare la visualizzazione a cartelle per le foto e i video sul file system", "force_re-scan_library_files": "Forza nuova scansione di tutti i file della libreria", "forward": "Avanti", "general": "Generale", @@ -743,16 +793,16 @@ "host": "Host", "hour": "Ora", "image": "Immagine", - "image_alt_text_date": "{isVideo, select, true {Video} other {Immagine}} scattato il {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} scattata con {person1} il giorno {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1} e {person2} il giorno {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} scattata con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", - "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} il giorno {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} il giorno {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1} e {person2} il giorno {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} scattata a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Immagine}} scattato a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", + "image_alt_text_date": "{isVideo, select, true {Video girato} other {Foto scattata}} il {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} il giorno {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} con {person1}, {person2}, e altre {additionalCount, number} persone il giorno {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} il giorno {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} il giorno {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1} e {person2} il giorno {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2}, e {person3} il giorno {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video girato} other {Foto scattata}} a {city}, {country} con {person1}, {person2} e {additionalCount, number} altre persone il {date}", "image_alt_text_people": "{count, plural, =1 {con {person1}} =2 {con {person1} e {person2}} =3 {con {person1}, {person2} e {person3}} other {con {person1}, {person2} e {others, number} altri}}", "image_alt_text_place": "a {city}, {country}", "image_taken": "{isVideo, select, true {Video registrato} other {Immagine scattata}}", @@ -781,7 +831,7 @@ "jobs": "Processi", "keep": "Mantieni", "keep_all": "Tieni tutto", - "keyboard_shortcuts": "Comandi rapidi", + "keyboard_shortcuts": "Scorciatoie da tastiera", "language": "Lingua", "language_setting_description": "Seleziona la tua lingua predefinita", "last_seen": "Ultimo accesso", @@ -819,6 +869,7 @@ "license_trial_info_4": "Per favore considera sborsare soldi per una licenza e per sopportare il continuo sviluppo del servizio", "light": "Chiaro", "like_deleted": "Mi piace rimosso", + "link_motion_video": "Collega video in movimento", "link_options": "Impostazioni Collegamento", "link_to_oauth": "Collegamento a OAuth", "linked_oauth_account": "Account OAuth collegato", @@ -826,7 +877,7 @@ "loading": "Caricamento", "loading_search_results_failed": "Impossibile caricare i risultati della ricerca", "log_out": "Esci", - "log_out_all_devices": "Esci da tutti i dispositivi", + "log_out_all_devices": "Disconnetti tutti i dispositivi", "logged_out_all_devices": "Disconnesso da tutti i dispositivi", "logged_out_device": "Disconnesso dal dispositivo", "login": "Login", @@ -837,6 +888,7 @@ "look": "Guarda", "loop_videos": "Riproduci video in loop", "loop_videos_description": "Abilita per riprodurre automaticamente un video in loop nella vista dettagli.", + "main_branch_warning": "Stai usando una versione di sviluppo. Consigliamo vivamente di utilizzare una versione di rilascio!", "make": "Produttore", "manage_shared_links": "Gestisci link condivisi", "manage_sharing_with_partners": "Gestisci la condivisione con i compagni", @@ -844,7 +896,7 @@ "manage_your_account": "Gestisci il tuo account", "manage_your_api_keys": "Gestisci le tue chiavi API", "manage_your_devices": "Gestisci i tuoi dispositivi collegati", - "manage_your_oauth_connection": "Gestisci la tua connesione OAuth", + "manage_your_oauth_connection": "Gestisci la tua connessione OAuth", "map": "Mappa", "map_marker_for_images": "Indicatore mappa per le immagini scattate in {city}, {country}", "map_marker_with_image": "Segnaposto con immagine", @@ -861,7 +913,7 @@ "merge_people_limit": "Puoi unire al massimo 5 volti alla volta", "merge_people_prompt": "Vuoi unire queste persone? Questa azione è irreversibile.", "merge_people_successfully": "Unione persone completata con successo", - "merged_people_count": "Uniti {count, plural, one {# persona} other {# persone}}", + "merged_people_count": "{count, plural, one {Unita # persona} other {Unite # persone}}", "minimize": "Minimizza", "minute": "Minuto", "missing": "Mancante", @@ -884,8 +936,8 @@ "next_memory": "Prossima memoria", "no": "No", "no_albums_message": "Crea un album per organizzare le tue foto ed i tuoi video", - "no_albums_with_name_yet": "Nessun album con questo nome, per ora.", - "no_albums_yet": "Nessun album presente, per ora.", + "no_albums_with_name_yet": "Sembra che tu non abbia ancora nessun album con questo nome.", + "no_albums_yet": "Sembra che tu non abbia ancora nessun album.", "no_archived_assets_message": "Archivia foto e video per nasconderli dalla galleria di foto", "no_assets_message": "CLICCA PER CARICARE LA TUA PRIMA FOTO", "no_duplicates_found": "Nessun duplicato trovato.", @@ -906,18 +958,21 @@ "notifications": "Notifiche", "notifications_setting_description": "Gestisci notifiche", "oauth": "OAuth", + "official_immich_resources": "Risorse Ufficiali Immich", "offline": "Offline", "offline_paths": "Percorsi offline", "offline_paths_description": "Questi risultati potrebbero essere causati dall'eliminazione manuale di file che non fanno parte di una libreria esterna.", "ok": "Ok", "oldest_first": "Prima vecchi", "onboarding": "Inserimento", + "onboarding_privacy_description": "Le seguenti funzioni (opzionali) fanno uso di servizi esterni, e possono essere disabilitate in qualsiasi momento nelle impostazioni di amministrazione.", "onboarding_theme_description": "Scegli un tema colore per la tua istanza. Potrai cambiarlo nelle impostazioni.", - "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcuni settaggi comuni.", + "onboarding_welcome_description": "Andiamo ad impostare la tua istanza con alcune impostazioni comuni.", "onboarding_welcome_user": "Benvenuto, {user}", "online": "Online", "only_favorites": "Solo preferiti", "only_refreshes_modified_files": "Aggiorna solo i file modificati", + "open_in_map_view": "Apri nella visualizzazione mappa", "open_in_openstreetmap": "Apri su OpenStreetMap", "open_the_search_filters": "Apri filtri di ricerca", "options": "Opzioni", @@ -952,7 +1007,8 @@ "pending": "In attesa", "people": "Persone", "people_edits_count": "{count, plural, one {Modificata # persona} other {Modificate # persone}}", - "people_sidebar_description": "Mosta un link alle persone nella barra laterale", + "people_feature_description": "Navigare foto e video raggruppati da persone", + "people_sidebar_description": "Mostra un link alle persone nella barra laterale", "perform_library_tasks": "", "permanent_deletion_warning": "Avviso eliminazione permanente", "permanent_deletion_warning_setting_description": "Mostra un avviso all'eliminazione definitiva di un asset", @@ -983,6 +1039,7 @@ "previous_memory": "Ricordo precedente", "previous_or_next_photo": "Precedente o prossima foto", "primary": "Primario", + "privacy": "Privacy", "profile_image_of_user": "Immagine profilo di {user}", "profile_picture_set": "Foto profilo impostata.", "public_album": "Album pubblico", @@ -1011,7 +1068,7 @@ "purchase_panel_title": "Contribuisci al progetto", "purchase_per_server": "Per server", "purchase_per_user": "Per utente", - "purchase_remove_product_key": "Rimuovi Chiave del Prodotto", + "purchase_remove_product_key": "Rimuovi la Chiave del Prodotto", "purchase_remove_product_key_prompt": "Sei sicuro di voler rimuovere la chiave del prodotto?", "purchase_remove_server_product_key": "Rimuovi la chiave del prodotto per Server", "purchase_remove_server_product_key_prompt": "Sei sicuro di voler rimuovere la chiave del prodotto per Server?", @@ -1020,6 +1077,10 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "La chiave del prodotto del server è gestita dall'amministratore", "range": "", + "rating": "Valutazione a stelle", + "rating_clear": "Crea valutazione", + "rating_count": "{count, plural, one {# stella} other {# stelle}}", + "rating_description": "Visualizza la valutazione EXIF nel pannello informazioni", "raw": "", "reaction_options": "Impostazioni Reazioni", "read_changelog": "Leggi Riepilogo Modifiche", @@ -1031,27 +1092,30 @@ "recent_searches": "Ricerche recenti", "refresh": "Aggiorna", "refresh_encoded_videos": "Ricarica video codificati", + "refresh_faces": "Aggiorna facce", "refresh_metadata": "Ricarica metadati", "refresh_thumbnails": "Ricarica anteprime", "refreshed": "Aggiornato", - "refreshes_every_file": "Aggiorna ogni file", + "refreshes_every_file": "Rilegge tutti i file esistenti e nuovi", "refreshing_encoded_video": "Ricaricando il video codificato", + "refreshing_faces": "Aggiorna Facce", "refreshing_metadata": "Ricaricando i metadati", "regenerating_thumbnails": "Rigenerando le anteprime", "remove": "Rimuovi", - "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} dall'album?", - "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# assets}} da questo link condiviso?", + "remove_assets_album_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} dall'album?", + "remove_assets_shared_link_confirmation": "Sei sicuro di voler rimuovere {count, plural, one {# asset} other {# asset}} da questo link condiviso?", "remove_assets_title": "Rimuovere asset?", - "remove_custom_date_range": "Cancella intervallo data personalizzato", + "remove_custom_date_range": "Rimuovi intervallo data personalizzato", + "remove_deleted_assets": "Rimuovi file offline", "remove_from_album": "Rimuovere dall'album", "remove_from_favorites": "Rimuovi dai preferiti", "remove_from_shared_link": "Rimuovi dal link condiviso", - "remove_offline_files": "Rimuovi file offline", "remove_user": "Rimuovi utente", "removed_api_key": "Rimossa chiave API: {name}", "removed_from_archive": "Rimosso dall'archivio", "removed_from_favorites": "Rimosso dai preferiti", - "removed_from_favorites_count": "{count, plural, other {Rimossi #}} dai preferiti", + "removed_from_favorites_count": "{count, plural, one {Rimosso } other {Rimossi #}} dai preferiti", + "removed_tagged_assets": "Rimossa etichetta {count, plural, one {# dall'asset} other {# dagli asset}}", "rename": "Rinomina", "repair": "Ripara", "repair_no_results_message": "I file mancanti e non tracciati saranno mostrati qui", @@ -1082,7 +1146,8 @@ "saved_settings": "Impostazioni salvate", "say_something": "Dici qualcosa", "scan_all_libraries": "Analizza tutte le librerie", - "scan_all_library_files": "Ri-analizza Tutti i File della Libreria", + "scan_all_library_files": "Scansiona nuovamente tutti i file della libreria", + "scan_library": "Scan", "scan_new_library_files": "Analizza i File Nuovi della Libreria", "scan_settings": "Impostazioni Analisi", "scanning_for_album": "Sto cercando l'album...", @@ -1091,18 +1156,21 @@ "search_by_context": "Cerca con contesto", "search_by_filename": "Cerca per nome del file o estensione", "search_by_filename_example": "es. IMG_1234.JPG o PNG", - "search_camera_make": "Cerca manufattore fotocamera...", + "search_camera_make": "Cerca produttore fotocamera...", "search_camera_model": "Cerca modello fotocamera...", "search_city": "Cerca città...", "search_country": "Cerca paese...", "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_settings": "Cerca Impostazioni", "search_state": "Cerca stato...", + "search_tags": "Cerca tag...", "search_timezone": "Cerca fuso orario...", - "search_type": "Certa tipo", + "search_type": "Cerca tipo", "search_your_photos": "Cerca le tue foto", "searching_locales": "Cerca localizzazioni...", "second": "Secondo", @@ -1120,7 +1188,7 @@ "select_photos": "Seleziona foto", "select_trash_all": "Seleziona cestina tutto", "selected": "Selezionato", - "selected_count": "{count, plural, other {# selezionati}}", + "selected_count": "{count, plural, one {# selezionato} other {# selezionati}}", "send_message": "Manda messaggio", "send_welcome_email": "Invia email di benvenuto", "server": "Server", @@ -1133,7 +1201,7 @@ "set_as_profile_picture": "Imposta come foto profilo", "set_date_of_birth": "Imposta data di nascita", "set_profile_picture": "Imposta foto profilo", - "set_slideshow_to_fullscreen": "Imposta diapositiva a schermo intero", + "set_slideshow_to_fullscreen": "Imposta presentazione a schermo intero", "settings": "Impostazioni", "settings_saved": "Impostazioni salvate", "share": "Condivisione", @@ -1142,6 +1210,7 @@ "shared_by_user": "Condiviso da {user}", "shared_by_you": "Condiviso da te", "shared_from_partner": "Foto da {partner}", + "shared_link_options": "Opzioni link condiviso", "shared_links": "Link condivisi", "shared_photos_and_videos_count": "{assetCount, plural, other {# foto & video condivisi.}}", "shared_with_partner": "Condiviso con {partner}", @@ -1150,6 +1219,7 @@ "sharing_sidebar_description": "Mostra un link a Condivisione nella barra laterale", "shift_to_permanent_delete": "premi ⇧ per cancellare definitivamente l'asset", "show_album_options": "Mostra opzioni album", + "show_albums": "Mostra gli album", "show_all_people": "Mostra tutte le persone", "show_and_hide_people": "Mostra & nascondi persone", "show_file_location": "Mostra percorso file", @@ -1164,13 +1234,18 @@ "show_person_options": "Mostra opzioni persona", "show_progress_bar": "Mostra Barra Avanzamento", "show_search_options": "Mostra impostazioni di ricerca", - "show_supporter_badge": "Insignia di Contributore", - "show_supporter_badge_description": "Mostra un'insignia di contributore", - "shuffle": "Mescola", + "show_slideshow_transition": "Mostra la transizione della presentazione", + "show_supporter_badge": "Medaglia di Contributore", + "show_supporter_badge_description": "Mostra la medaglia di contributore", + "shuffle": "Casuale", + "sidebar": "Barra laterale", + "sidebar_display_description": "Visualizzare un link alla vista nella barra laterale", "sign_out": "Esci", "sign_up": "Registrati", "size": "Dimensione", "skip_to_content": "Salta al contenuto", + "skip_to_folders": "Salta alle cartelle", + "skip_to_tags": "Salta alle etichette", "slideshow": "Presentazione", "slideshow_settings": "Impostazioni presentazione", "sort_albums_by": "Ordina album per...", @@ -1182,39 +1257,55 @@ "sort_title": "Titolo", "source": "Fonte", "stack": "Raggruppa", + "stack_duplicates": "Raggruppa i duplicati", + "stack_select_one_photo": "Seleziona una foto principale per il gruppo", "stack_selected_photos": "Impila foto selezionate", - "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # assets}}", + "stacked_assets_count": "{count, plural, one {Raggruppato # asset} other {Raggruppati # asset}}", "stacktrace": "Traccia dell'errore", "start": "Inizio", "start_date": "Data di inizio", "state": "Provincia", "status": "Stato", "stop_motion_photo": "Ferma Foto in Movimento", - "stop_photo_sharing": "Stoppare la condivisione delle tue foto?", + "stop_photo_sharing": "Interrompere la condivisione delle tue foto?", "stop_photo_sharing_description": "{partner} non potrà più accedere alle tue foto.", - "stop_sharing_photos_with_user": "Non condividere più le tue foto con questo utente", + "stop_sharing_photos_with_user": "Interrompi la condivisione delle tue foto con questo utente", "storage": "Spazio di archiviazione", "storage_label": "Etichetta archiviazione", "storage_usage": "{used} di {available} utilizzati", "submit": "Invia", "suggestions": "Suggerimenti", "sunrise_on_the_beach": "Tramonto sulla spiaggia", + "support": "Supporto", + "support_and_feedback": "Supporto & Feedback", + "support_third_party_description": "La tua installazione di Immich è stata costruita da terze parti. I problemi che riscontri potrebbero essere causati da altri pacchetti, quindi ti preghiamo di sollevare il problema in prima istanza utilizzando i link sottostanti.", "swap_merge_direction": "Scambia direzione di unione", "sync": "Sincronizza", + "tag": "Tag", + "tag_assets": "Tagga risorse", + "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_updated": "Tag {tag} aggiornata", + "tagged_assets": "{count, plural, one {# asset etichettato} other {# asset etichettati}}", + "tags": "Tag", "template": "Modello", "theme": "Tema", "theme_selection": "Selezione tema", "theme_selection_description": "Imposta automaticamente il tema chiaro o scuro in base all'impostazione del tuo browser", "they_will_be_merged_together": "Verranno uniti insieme", + "third_party_resources": "Risorse di Terze Parti", "time_based_memories": "Ricordi basati sul tempo", "timezone": "Fuso orario", "to_archive": "Archivio", "to_change_password": "Modifica password", "to_favorite": "Preferito", "to_login": "Login", + "to_parent": "Sali di un livello", + "to_root": "Alla radice", "to_trash": "Cancella", "toggle_settings": "Attiva/disattiva impostazioni", - "toggle_theme": "Cambia tema", + "toggle_theme": "Abilita tema scuro", "toggle_visibility": "Cambia visibilità", "total_usage": "Utilizzo totale", "trash": "Cestino", @@ -1224,7 +1315,7 @@ "trash_no_results_message": "Le foto cestinate saranno mostrate qui.", "trashed_items_will_be_permanently_deleted_after": "Gli elementi cestinati saranno eliminati definitivamente dopo {days, plural, one {# giorno} other {# giorni}}.", "type": "Tipo", - "unarchive": "Rimuovi dagli archivi", + "unarchive": "Annulla l'archiviazione", "unarchived": "Rimosso dall'archivio", "unarchived_count": "{count, plural, other {Non archiviati #}}", "unfavorite": "Rimuovi preferito", @@ -1233,24 +1324,26 @@ "unknown_album": "Album sconosciuto", "unknown_year": "Anno sconosciuto", "unlimited": "Illimitato", + "unlink_motion_video": "Scollega video in movimento", "unlink_oauth": "Scollega OAuth", "unlinked_oauth_account": "Scollega account OAuth", "unnamed_album": "Album senza nome", + "unnamed_album_delete_confirmation": "Sei sicuro di voler eliminare questo album?", "unnamed_share": "Condivisione senza nome", "unsaved_change": "Modifica non salvata", "unselect_all": "Deseleziona tutto", "unselect_all_duplicates": "Deseleziona tutti i duplicati", "unstack": "Rimuovi dal gruppo", - "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # assets}}", + "unstacked_assets_count": "{count, plural, one {Separato # asset} other {Separati # asset}}", "untracked_files": "File non tracciati", "untracked_files_decription": "Questi file non vengono tracciati dall'applicazione. Sono il risultato di spostamenti falliti, caricamenti interrotti, oppure sono stati abbandonati a causa di un bug", "up_next": "Prossimo", "updated_password": "Password aggiornata", "upload": "Carica", "upload_concurrency": "Caricamenti contemporanei", - "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli assets caricati.", + "upload_errors": "Caricamento completato con {count, plural, one {# errore} other {# errori}}, ricarica la pagina per vedere gli asset caricati.", "upload_progress": "Rimanenti {remaining, number} - Processati {processed, number}/{total, number}", - "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # assets duplicati}}", + "upload_skipped_duplicates": "{count, plural, one {Ignorato # asset duplicato} other {Ignorati # asset duplicati}}", "upload_status_duplicates": "Duplicati", "upload_status_errors": "Errori", "upload_status_uploaded": "Caricato", @@ -1274,7 +1367,9 @@ "variables": "Variabili", "version": "Versione", "version_announcement_closing": "Il tuo amico, Alex", - "version_announcement_message": "Heilà! È stata rilasciata una nuova versione dell'applicazione. Leggi le note di rilascio e assicurati che i tuoi file docker-compose.yml/.env siano aggiornati per evitare problemi e incongruenze, sopratutto se utilizzi WatchTower o altri strumenti per aggiornare l'applicazione in automatico.", + "version_announcement_message": "Ehilà! È stata rilasciata una nuova versione dell'applicazione. 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 l'applicazione in automatico.", + "version_history": "Storico delle Versioni", + "version_history_item": "Versione installata {version} il {date}", "video": "Video", "video_hover_setting": "Riproduci l'anteprima del video al passaggio del mouse", "video_hover_setting_description": "Riproduci miniatura video quando il mouse passa sopra l'elemento. Anche se disabilitato, la riproduzione può essere avviata passando con il mouse sopra l'icona riproduci.", @@ -1284,6 +1379,7 @@ "view_album": "Visualizza Album", "view_all": "Vedi tutto", "view_all_users": "Visualizza tutti gli utenti", + "view_in_timeline": "Visualizza in timeline", "view_links": "Visualizza i link", "view_next_asset": "Visualizza risorsa successiva", "view_previous_asset": "Visualizza risorsa precedente", @@ -1294,7 +1390,7 @@ "warning": "Attenzione", "week": "Settimana", "welcome": "Benvenuto", - "welcome_to_immich": "Benvenuto a immich", + "welcome_to_immich": "Benvenuto in Immich", "year": "Anno", "years_ago": "{years, plural, one {# anno} other {# anni}} fa", "yes": "Si", diff --git a/web/src/lib/i18n/ja.json b/i18n/ja.json similarity index 85% rename from web/src/lib/i18n/ja.json rename to i18n/ja.json index 4e01b62850..c220828e54 100644 --- a/web/src/lib/i18n/ja.json +++ b/i18n/ja.json @@ -25,7 +25,7 @@ "add_to_shared_album": "共有アルバムに追加", "added_to_archive": "アーカイブに追加済", "added_to_favorites": "お気に入りに追加済", - "added_to_favorites_count": "{count} 画像をお気に入りに追加済", + "added_to_favorites_count": "{count, number} 枚の画像をお気に入りに追加済", "admin": { "add_exclusion_pattern_description": "除外パターンを追加します。ワイルドカード「*」「**」「?」を使用できます。すべてのディレクトリで「Raw」と名前が付いたファイルを無視するには、「**/Raw/**」を使用します。また、「.tif」で終わるファイルをすべて無視するには、「**/*.tif」を使用します。さらに、絶対パスを無視するには「/path/to/ignore/**」を使用します。", "authentication_settings": "認証設定", @@ -37,7 +37,7 @@ "cleared_jobs": "{job}のジョブをクリアしました", "config_set_by_file": "設定は現在 Config File で設定されている", "confirm_delete_library": "本当に {library} を削除しますか?", - "confirm_delete_library_assets": "本当にこのライブラリを削除しますか? {count, plural, one {#個のアセット} other {all #個のアセット全て}} がImmichから削除され、元に戻すことはできません。ファイルはディスク上に残ります。", + "confirm_delete_library_assets": "本当にこのライブラリを削除しますか? {count, plural, one {#個のアセット} other {#個のアセット全て}} がImmichから削除され、元に戻すことはできません。ファイルはディスク上に残ります。", "confirm_email_below": "確認のため、以下に \"{email}\" と入力してください", "confirm_reprocess_all_faces": "本当にすべての顔を再処理しますか? これにより名前が付けられた人物も消去されます。", "confirm_user_password_reset": "本当に {user} のパスワードをリセットしますか?", @@ -49,8 +49,8 @@ "external_library_created_at": "外部ライブラリ(作成日:{date})", "external_library_management": "外部ライブラリ管理", "face_detection": "顔検出", - "face_detection_description": "機械学習を使用してアセット内の顔を検出します。動画の場合は、サムネイルのみが対象となります。\"All\" はすべてのアセットを(再)処理します。 \"Missing\" はまだ処理されていないアセットをキューに入れます。顔検出の完了後、検出された顔は顔認識のキューへ入れられ、既存または新規の人物にグループ化されます。", - "facial_recognition_job_description": "検出された顔を人物にグループ化します。このステップは顔検出が完了した後に実行されます。 \"All\" はすべての顔を(再)クラスタリングし、 \"Missing\" は人物が割り当てられていない顔をキューに入れます。", + "face_detection_description": "機械学習を使用してアセット内の顔を検出します。動画の場合は、サムネイルのみが対象となります。\"すべて\" はすべてのアセットを(再)処理します。 \"欠落\" はまだ処理されていないアセットをキューに入れます。顔検出の完了後、検出された顔は顔認識のキューへ入れられ、既存または新規の人物にグループ化されます。", + "facial_recognition_job_description": "検出された顔を人物にグループ化します。このステップは顔検出が完了した後に実行されます。 \"すべて\" はすべての顔を(再)クラスタリングし、 \"欠落\" は人物が割り当てられていない顔をキューに入れます。", "failed_job_command": "ジョブ {job}のコマンド {command}が失敗しました", "force_delete_user_warning": "警告:この操作を行うと、ユーザーとすべてのアセットが直ちに削除されます。これは元に戻せず、ファイルも復元できません。", "forcing_refresh_library_files": "すべてのライブラリファイルを強制更新", @@ -126,15 +126,16 @@ "manage_concurrency": "同時実行数の管理", "manage_log_settings": "ログ設定を管理します", "map_dark_style": "ダークモード", - "map_enable_description": "地図表示を有効にします", + "map_enable_description": "地図表示機能を有効にします", "map_gps_settings": "地図・GPS設定", "map_gps_settings_description": "地図とGPS(逆ジオコーディング)の設定を管理します", + "map_implications": "地図表示機能は外部のタイルサービス(tiles.immich.cloud)に依存します", "map_light_style": "ライトモード", "map_manage_reverse_geocoding_settings": "逆ジオコーディングの設定を管理します", "map_reverse_geocoding": "逆ジオコーディング", "map_reverse_geocoding_enable_description": "逆ジオコーディング(緯度経度から住所を生成)を有効にする", "map_reverse_geocoding_settings": "逆ジオコーディング設定", - "map_settings": "地図・GPS設定", + "map_settings": "地図", "map_settings_description": "地図設定", "map_style_description": "マップテーマ(style.json)の参照先URL", "metadata_extraction_job": "メタデータの展開", @@ -147,7 +148,7 @@ "note_cannot_be_changed_later": "注意: 後から変更できません!", "note_unlimited_quota": "注意: 無制限にする場合は0を入力してください", "notification_email_from_address": "送信メールアドレス", - "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", + "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", "notification_email_host_description": "送信メールサーバーを設定します(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "証明書エラーを無視", "notification_email_ignore_certificate_errors_description": "TLS証明書の検証エラーを無視します(非推奨)", @@ -173,15 +174,17 @@ "oauth_issuer_url": "発行元URL", "oauth_mobile_redirect_uri": "モバイル用リダイレクトURI", "oauth_mobile_redirect_uri_override": "モバイル用リダイレクトURI(上書き)", - "oauth_mobile_redirect_uri_override_description": "\"app.immich:/\" が無効なリダイレクトURIである場合に有効にします。", + "oauth_mobile_redirect_uri_override_description": "'{callback}'など、モバイルURIがOAuthプロバイダーによって許可されていない場合に有効にしてください", + "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_storage_label_claim": "", + "oauth_storage_label_claim": "ストレージラベル クレーム", "oauth_storage_label_claim_description": "ユーザーのストレージラベルを、このクレームの値に自動的に設定します。", - "oauth_storage_quota_claim": "", + "oauth_storage_quota_claim": "ストレージクォータ クレーム", "oauth_storage_quota_claim_description": "ユーザーのストレージクォータをこのクレームの値に自動的に設定します。", "oauth_storage_quota_default": "デフォルトのストレージ割り当て(GiB)", "oauth_storage_quota_default_description": "クレームが提供されていない場合に使用されるクォータをGiB単位で設定します(無制限にする場合は0を入力してください)。", @@ -195,10 +198,10 @@ "refreshing_all_libraries": "すべてのライブラリを更新", "registration": "管理者登録", "registration_description": "あなたはシステムの最初のユーザーであるため、管理者として割り当てられ、管理タスクを担当し、追加のユーザーはあなたによって作成されます。", - "removing_offline_files": "オフライン ファイルを削除します", + "removing_deleted_files": "オフライン ファイルを削除します", "repair_all": "すべてを修復", - "repair_matched_items": "一致: {count, plural, one {# item} other {# items}}", - "repaired_items": "修復済み: {count, plural, one {# item} other {# items}}", + "repair_matched_items": "一致: {count, plural, one {#件} other {#件}}", + "repaired_items": "修復済み: {count, plural, one {#件} other {#件}}", "require_password_change_on_login": "初回ログイン時にパスワード変更を要求する", "reset_settings_to_default": "設定をデフォルトにリセットします", "reset_settings_to_recent_saved": "前回の設定値に戻す", @@ -276,7 +279,7 @@ "transcoding_preferred_hardware_device": "推奨ハードウェアデバイス", "transcoding_preferred_hardware_device_description": "VAAPI と QSV のみに適用されます。 ハードウェアトランスコードに使用されるdriノードを設定します。", "transcoding_preset_preset": "プリセット (-preset)", - "transcoding_preset_preset_description": "圧縮速度。遅いプリセットはファイルサイズを小さくし、特定のビットレートを目標とする場合に品質を向上させます。VP9は `faster` 以上の速度を無視します。", + "transcoding_preset_preset_description": "圧縮速度。遅いプリセットはファイルサイズを小さくし、特定のビットレートを目標とする場合に品質を向上させます。VP9は 'faster'以上の速度を無視します。", "transcoding_reference_frames": "参照フレーム", "transcoding_reference_frames_description": "特定のフレームを圧縮するときに参照するフレームの数。より高い値は圧縮効率を改善しますが、エンコードが遅くなります。\"0\" に設定すると、この値が自動的に設定されます。", "transcoding_required_description": "許容されていない動画形式のみ", @@ -284,7 +287,7 @@ "transcoding_settings_description": "動画ファイルの解像度とエンコード情報を管理します", "transcoding_target_resolution": "解像度", "transcoding_target_resolution_description": "解像度を高くすると細かなディテールを保持できますが、エンコードに時間がかかり、ファイルサイズが大きくなり、アプリの応答性が低下する可能性があります。", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "適応的量子化(Temporal AQ)", "transcoding_temporal_aq_description": "NVEncにのみ適用されます。高精細で動きの少ないシーンの画質を向上させます。古いデバイスとの互換性はありません。", "transcoding_threads": "スレッド数", "transcoding_threads_description": "値を高くするとエンコード速度が速くなりますが、アクティブな間はサーバーが他のタスクを処理する余裕が少なくなります。この値はCPUのコア数を超えないようにする必要があります。\"0\" に設定すると、最大限利用されます。", @@ -318,7 +321,8 @@ "user_settings": "ユーザー設定", "user_settings_description": "ユーザー設定を管理します", "user_successfully_removed": "ユーザー {email} は正常に削除されました。", - "version_check_enabled_description": "新しいリリースを確認するための定期的なGitHubへのリクエストを有効にする", + "version_check_enabled_description": "バージョンの確認を有効にする", + "version_check_implications": "このバージョン確認機能は定期的なgithub.comとの通信によります", "version_check_settings": "バージョンチェック", "version_check_settings_description": "新しいバージョンの通知を有効/無効にします", "video_conversion_job": "動画をトランスコード", @@ -328,10 +332,14 @@ "admin_password": "管理者パスワード", "administration": "管理", "advanced": "詳細設定", + "age_months": "{months,plural, one {#か月} other {#か月}}", + "age_year_months": "1歳{months,plural, one {#か月} other {#か月}}", + "age_years": "{years,plural, one {#歳} other {#歳}}", "album_added": "アルバム追加", "album_added_notification_setting_description": "共有アルバムに追加されたときEメール通知を受信する", "album_cover_updated": "アルバムカバー更新", - "album_delete_confirmation": "アルバム{album}を本当に削除しますか?\nこのアルバムが共有されているなら、他のユーザーもアルバムにアクセスできなくなります。", + "album_delete_confirmation": "アルバム{album}を本当に削除しますか?", + "album_delete_confirmation_description": "このアルバムが共有されているなら、他のユーザーもアルバムにアクセスできなくなります。", "album_info_updated": "アルバム情報更新", "album_leave": "アルバムから去りますか?", "album_leave_confirmation": "本当に {album} から去りますか?", @@ -355,6 +363,7 @@ "allow_edits": "編集を許可", "allow_public_user_to_download": "一般ユーザーによるダウンロードを許可", "allow_public_user_to_upload": "一般ユーザーによるアップロードを許可", + "anti_clockwise": "反時計回り", "api_key": "APIキー", "api_key_description": "この値は一回のみ表示されます。 ウィンドウを閉じる前に必ずコピーしてください。", "api_key_empty": "APIキー名は空白にできません", @@ -410,7 +419,7 @@ "camera_model": "カメラモデル", "cancel": "キャンセル", "cancel_search": "検索をキャンセル", - "cannot_merge_people": "人物をマージできません", + "cannot_merge_people": "人物を統合できません", "cannot_undo_this_action": "この操作は元に戻せません!", "cannot_update_the_description": "説明を更新できません", "cant_apply_changes": "", @@ -426,16 +435,20 @@ "change_password_description": "これは、初めてのサインインであるか、パスワードの変更要求が行われたかのいずれかです。 新しいパスワードを下に入力してください。", "change_your_password": "パスワードを変更します", "changed_visibility_successfully": "非表示設定を正常に変更しました", + "check_all": "全て選択", "check_logs": "ログを確認", + "choose_matching_people_to_merge": "統合先の人物を選んでください", "city": "市町村", "clear": "クリア", "clear_all": "全てクリア", "clear_all_recent_searches": "全ての最近の検索をクリア", "clear_message": "メッセージをクリア", "clear_value": "値をクリア", + "clockwise": "時計回り", "close": "閉じる", "collapse": "展開", "collapse_all": "全て展開", + "color": "カラー", "color_theme": "カラーテーマ", "comment_deleted": "コメントが削除されました", "comment_options": "コメント設定", @@ -469,18 +482,20 @@ "create_new_person": "新しい人物を作成", "create_new_person_hint": "選択されたアセットを新しい人物に割り当て", "create_new_user": "新規ユーザーの作成", + "create_tag": "タグを作成する", + "create_tag_description": "タグを作成します。入れ子構造のタグは、はじめのスラッシュを含めた、タグの完全なパスを入力してください。", "create_user": "ユーザーを作成", "created": "作成", "current_device": "現在のデバイス", "custom_locale": "カスタムロケール", "custom_locale_description": "言語と地域に基づいて日付と数値をフォーマットします", - "dark": "", + "dark": "ダークモード", "date_after": "この日以降", "date_and_time": "日付と時間", "date_before": "この日以前", "date_of_birth_saved": "生年月日は正常に保存されました", "date_range": "日付", - "day": "", + "day": "ライトモード", "deduplicate_all": "全て重複排除", "default_locale": "デフォルトのロケール", "default_locale_description": "ブラウザのロケールに基づいて日付と数値をフォーマットします", @@ -492,6 +507,8 @@ "delete_library": "ライブラリを削除", "delete_link": "リンクを削除", "delete_shared_link": "共有リンクを消す", + "delete_tag": "タグを削除する", + "delete_tag_confirmation_prompt": "本当に{tagName}タグを削除しますか?", "delete_user": "ユーザーを削除", "deleted_shared_link": "共有リンクを削除", "description": "概要欄", @@ -509,12 +526,15 @@ "do_not_show_again": "このメッセージを再び表示しない", "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": "間隔", "durations": { "days": "", @@ -537,10 +557,15 @@ "edit_location": "位置情報を編集", "edit_name": "名前を変更", "edit_people": "人物を編集", + "edit_tag": "タグを編集する", "edit_title": "タイトルを編集", "edit_user": "ユーザーを編集", - "edited": "", - "editor": "", + "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": "", "empty_album": "", @@ -581,6 +606,8 @@ "failed_to_load_assets": "アセットを読み込めませんでした", "failed_to_load_people": "人物を読み込めませんでした", "failed_to_remove_product_key": "プロダクトキーを削除できませんでした", + "failed_to_stack_assets": "アセットをスタックできませんでした", + "failed_to_unstack_assets": "アセットをスタックから解除することができませんでした", "import_path_already_exists": "このインポートパスは既に存在します。", "incorrect_email_or_password": "メールアドレスまたはパスワードが間違っています", "paths_validation_failed": "{paths, plural, one {#個} other {#個}}のパスの検証に失敗しました", @@ -644,8 +671,8 @@ "unable_to_remove_api_key": "API キーを削除できません", "unable_to_remove_assets_from_shared_link": "共有リンクからアセットを削除できません", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "オフラインのファイルを削除できません", "unable_to_remove_library": "ライブラリを削除できません", - "unable_to_remove_offline_files": "オフラインのファイルを削除できません", "unable_to_remove_partner": "パートナーを削除できません", "unable_to_remove_reaction": "リアクションを削除できません", "unable_to_remove_user": "", @@ -667,12 +694,13 @@ "unable_to_set_profile_picture": "プロフィール画像を設定できません", "unable_to_submit_job": "ジョブを送信できません", "unable_to_trash_asset": "アセットをゴミ箱に移動できません", - "unable_to_unlink_account": "", + "unable_to_unlink_account": "アカウントのリンクを解除できません", "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_timeline_display_status": "タイムラインでの表示の設定状態を更新できません", "unable_to_update_user": "ユーザーを更新できません", "unable_to_upload_file": "ファイルをアップロードできません" }, @@ -687,6 +715,7 @@ "expired": "有効期限が切れました", "expires_date": "{date} に失効", "explore": "探索", + "explorer": "探索", "export": "エクスポート", "export_as_json": "JSONとしてエクスポート", "extension": "拡張子", @@ -700,6 +729,8 @@ "feature": "", "feature_photo_updated": "人物画像が更新されました", "featurecollection": "", + "features": "機能", + "features_setting_description": "アプリの機能を管理する", "file_name": "ファイル名", "file_name_or_extension": "ファイル名または拡張子", "filename": "ファイル名", @@ -708,10 +739,12 @@ "filter_people": "人物を絞り込み", "find_them_fast": "名前で検索して素早く発見", "fix_incorrect_match": "間違った一致を修正", + "folders": "フォルダ", + "folders_feature_description": "ファイルシステム上の写真と動画のフォルダビューを閲覧する", "force_re-scan_library_files": "強制的に全てのライブラリのファイルを再スキャン", "forward": "前へ", "general": "一般", - "get_help": "", + "get_help": "助けを求める", "getting_started": "はじめる", "go_back": "戻る", "go_to_search": "検索へ", @@ -720,8 +753,8 @@ "group_no": "グループ化なし", "group_owner": "所有者でグループ化", "group_year": "年でグループ化", - "has_quota": "", - "hi_user": "こんにちは、{name} {email}", + "has_quota": "クォータ有り", + "hi_user": "こんにちは、{name}( {email})さん", "hide_all_people": "全ての人物を非表示", "hide_gallery": "ギャラリーを非表示", "hide_named_person": "人物 {name} を非表示", @@ -732,6 +765,15 @@ "hour": "時間", "image": "写真", "image_alt_text_date": "{isVideo, select, true {動画} other {写真}}は{date} に撮影", + "image_alt_text_date_1_person": "{date}の、{person1}との{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_2_people": "{date}の、{person1}と{person2}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_3_people": "{date}の、{person1}と{person2}、そして{person3}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_4_or_more_people": "{date}の、{person1}と{person2}、そしてその他{additionalCount,number}人の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place": "{date}の、{country}、{city}での{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_1_person": "{date}の、{country}、{city}での{person1}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_2_people": "{date}の、{country}、{city}での{person1}と{person2}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_3_people": "{date}の、{country}、{city}での{person1}と{person2}、そして{person3}の{isVideo, select, true {動画} other {画像}}", + "image_alt_text_date_place_4_or_more_people": "{date}の、{country}、{city}での{person1}と{person2}、そしてその他{additionalCount, number}人の{isVideo, select, true {動画} other {画像}}", "image_alt_text_place": "{country} {city}で撮影", "image_taken": "{isVideo, select, true {動画は} other {写真は}}", "img": "", @@ -739,11 +781,12 @@ "immich_web_interface": "Immich Webインターフェース", "import_from_json": "JSONからインポート", "import_path": "インポートパス", + "in_albums": "{count, plural, one {#件のアルバム} other {#件のアルバム}}の中", "in_archive": "アーカイブ済み", "include_archived": "アーカイブ済みを含める", "include_shared_albums": "共有アルバムを含める", "include_shared_partner_assets": "パートナーがシェアしたアセットを含める", - "individual_share": "", + "individual_share": "1枚の共有", "info": "情報", "interval": { "day_at_onepm": "毎日午後1時", @@ -764,16 +807,16 @@ "last_seen": "最新の活動", "latest_version": "最新バージョン", "latitude": "緯度", - "leave": "", + "leave": "標高", "let_others_respond": "他のユーザーの返信を許可する", "level": "レベル", "library": "ライブラリ", "library_options": "ライブラリ設定", - "light": "", + "light": "ライトモード", "like_deleted": "いいねが削除されました", "link_options": "リンクのオプション", - "link_to_oauth": "", - "linked_oauth_account": "", + "link_to_oauth": "OAuthへリンクする", + "linked_oauth_account": "リンクされたOAuthアカウント", "list": "リスト", "loading": "読み込み中", "loading_search_results_failed": "検索結果を読み込めませんでした", @@ -799,23 +842,24 @@ "manage_your_oauth_connection": "OAuth接続を管理します", "map": "地図", "map_marker_for_images": "{country} {city}で撮影された写真の地図マーカー", - "map_marker_with_image": "", + "map_marker_with_image": "画像の地図マーカー", "map_settings": "マップの設定", "matches": "マッチ", "media_type": "メディアタイプ", "memories": "メモリー", "memories_setting_description": "メモリーの内容を管理します", "memory": "メモリー", + "memory_lane_title": "思い出 {title}", "menu": "メニュー", - "merge": "マージ", - "merge_people": "人物をマージ", + "merge": "統合", + "merge_people": "人物を統合", "merge_people_limit": "一度に結合できる顔は最大5つまでです", "merge_people_prompt": "これらの人物を統合しますか? この操作は元に戻せません。", - "merge_people_successfully": "", - "merged_people_count": "{count, plural, one {#人} other {#人}}の人物をマージしました", + "merge_people_successfully": "人物の統合に成功しました", + "merged_people_count": "{count, plural, one {#人} other {#人}}の人物を統合しました", "minimize": "最小化", "minute": "分", - "missing": "行方不明", + "missing": "欠落", "model": "モデル", "month": "月", "more": "もっと表示", @@ -824,6 +868,7 @@ "name": "名前", "name_or_nickname": "名前またはニックネーム", "never": "行わない", + "new_album": "新たなアルバム", "new_api_key": "新しいAPI キー", "new_password": "新しいパスワード", "new_person": "新しい人物", @@ -861,12 +906,15 @@ "offline_paths_description": "これらの結果は、外部ライブラリの一部ではないファイルを手動で削除したことが原因である可能性があります。", "ok": "了解", "oldest_first": "古い順", + "onboarding": "はじめに", + "onboarding_privacy_description": "次の(任意の)機能は外部サービスに依存し、いつでも管理者用設定で無効にできます。", "onboarding_theme_description": "インスタンスのカラーテーマを選択してください。これは後から設定で変更できます。", "onboarding_welcome_description": "いくつかの一般的な設定を使用してインスタンスをセットアップしましょう。", "onboarding_welcome_user": "ようこそ、{user} さん", "online": "オンライン", "only_favorites": "お気に入りのみ", "only_refreshes_modified_files": "変更されたファイルのみを更新します", + "open_in_map_view": "地図表示で見る", "open_in_openstreetmap": "OpenStreetMapで開く", "open_the_search_filters": "検索フィルタを開く", "options": "オプション", @@ -879,6 +927,7 @@ "owned": "所有中", "owner": "オーナー", "partner": "パートナー", + "partner_can_access": "{partner} がアクセスできます", "partner_can_access_assets": "アーカイブ済みのものと削除済みのものを除いた全ての写真と動画", "partner_can_access_location": "写真が撮影された場所", "partner_sharing": "パートナとの共有", @@ -888,9 +937,9 @@ "password_required": "パスワードが必要", "password_reset_success": "パスワードのリセットに成功", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {#日} other {#日}}前", + "hours": "{hours, plural, one {#時間} other {#時間}}前", + "years": "{years, plural, one {#年} other {#年}}前" }, "path": "パス", "pattern": "パターン", @@ -899,6 +948,8 @@ "paused": "停止", "pending": "待機中", "people": "人物", + "people_edits_count": "{count, plural, one {#人} other {#人}}が編集済", + "people_feature_description": "人物でグループ化された写真と動画を閲覧する", "people_sidebar_description": "人物へのリンクをサイドバーに表示", "perform_library_tasks": "", "permanent_deletion_warning": "永久削除の警告", @@ -909,10 +960,11 @@ "permanently_deleted_asset": "アセットを完全に削除しました", "permanently_deleted_assets_count": "{count, plural, one {#個} other {#個}}のアセットを完全に削除しました", "person": "人物", + "person_hidden": "{name}{hidden, select, true { (非表示)} other {}}", "photo_shared_all_users": "写真をすべてのユーザーと共有したか、共有するユーザーがいないようです。", "photos": "写真", "photos_and_videos": "写真と動画", - "photos_count": "{count, plural, one {{count, number}枚} other {{count, number}枚}}", + "photos_count": "{count, plural, one {{count, number}枚の写真} other {{count, number}枚の写真}}", "photos_from_previous_years": "以前の年の写真", "pick_a_location": "場所を選択", "place": "場所", @@ -922,13 +974,14 @@ "play_motion_photo": "モーションビデオを再生", "play_or_pause_video": "動画を再生または一時停止", "point": "", - "port": "", + "port": "ポートレート", "preset": "プリセット", "preview": "プレビュー", "previous": "前", "previous_memory": "前のメモリー", "previous_or_next_photo": "前または次の写真", - "primary": "", + "primary": "最優先", + "privacy": "プライバシー", "profile_image_of_user": "{user} のプロフィール画像", "profile_picture_set": "プロフィール画像が設定されました。", "public_album": "公開アルバム", @@ -946,11 +999,14 @@ "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の製作には多くの時間と労力を要しており、また、可能な限りImmichを良いものにするために取り組んでいる専任の技術者がいます。私たちの使命は、オープンソースソフトウェアであり倫理観に則したビジネスの実践のために、開発者の持続可能な収入源となること、そして搾取的なクラウドサービスの本当の代替サービスで、プライバシーを尊重したエコシステムをつくることです。", + "purchase_panel_info_2": "私たちは有料化しないことを約束していますので、この購入によってImmichに追加の機能が付与されることはありません。私たちは、皆様のようなImmichの継続的な開発を支援するユーザーに支えられています。", "purchase_panel_title": "プロジェクトを支援", "purchase_per_server": "サーバーごと", "purchase_per_user": "ユーザーごと", @@ -958,9 +1014,17 @@ "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": "サーバーのプロダクトキーは管理者に管理されています", "range": "", + "rating": "星での評価", + "rating_clear": "評価を取り消す", + "rating_count": "星{count, plural, one {#つ} other {#つ}}", + "rating_description": "情報欄にEXIFの評価を表示", "raw": "", - "reaction_options": "", + "reaction_options": "リアクションの選択", "read_changelog": "変更履歴を読む", "reassign": "再割り当て", "reassigned_assets_to_existing_person": "{count, plural, one {#個} other {#個}}のアセットを{name, select, null {既存の人物} other {{name}}}に再割り当てしました", @@ -982,15 +1046,16 @@ "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": "共有リンクから削除", - "remove_offline_files": "オフラインのファイルを削除", "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": "追跡されていないファイルや存在しないファイルがここに表示されます", @@ -1003,11 +1068,12 @@ "reset_people_visibility": "人物の非表示設定をリセット", "reset_settings_to_default": "", "reset_to_default": "デフォルトにリセット", + "resolve_duplicates": "重複を解決する", "resolved_all_duplicates": "全ての重複を解決しました", "restore": "復元", "restore_all": "全て復元", "restore_user": "ユーザーを復元", - "restored_asset": "復元されたアセット", + "restored_asset": "アセットを復元しました", "resume": "再開", "retry_upload": "アップロードを再試行", "review_duplicates": "重複を調査", @@ -1027,6 +1093,8 @@ "search": "検索", "search_albums": "アルバムを検索", "search_by_context": "状況で検索", + "search_by_filename": "ファイル名もしくは拡張子で検索", + "search_by_filename_example": "例: IMG_1234.JPG もしくは PNG", "search_camera_make": "カメラメーカーを検索…", "search_camera_model": "カメラのモデルを検索…", "search_city": "市町村を検索…", @@ -1037,6 +1105,7 @@ "search_people": "人物を検索", "search_places": "場所を検索", "search_state": "都道府県を検索…", + "search_tags": "タグを検索...", "search_timezone": "タイムゾーンを検索…", "search_type": "検索タイプ", "search_your_photos": "写真を検索", @@ -1045,6 +1114,7 @@ "see_all_people": "全ての人物を見る", "select_album_cover": "アルバムカバーを選択", "select_all": "全て選択", + "select_all_duplicates": "全ての重複を選択", "select_avatar_color": "アバターの色を選択", "select_face": "顔を選択", "select_featured_photo": "人物写真を選択", @@ -1054,10 +1124,13 @@ "select_new_face": "新しい顔を選択", "select_photos": "写真を選択", "select_trash_all": "全て削除", - "selected": "", + "selected": "選択済み", + "selected_count": "{count, plural, other {#個選択済み}}", "send_message": "メッセージを送信", "send_welcome_email": "ウェルカムメールを送信", "server": "サーバー", + "server_offline": "サーバーがオフラインです", + "server_online": "サーバーがオンラインです", "server_stats": "サーバー統計", "server_version": "サーバーバージョン", "set": "設定", @@ -1070,10 +1143,11 @@ "settings_saved": "設定が保存されました", "share": "共有", "shared": "共有済み", - "shared_by": "", + "shared_by": "により共有", "shared_by_user": "{user} により共有", "shared_by_you": "あなたにより共有", "shared_from_partner": "{partner} による写真", + "shared_link_options": "共有リンクのオプション", "shared_links": "共有リンク", "shared_photos_and_videos_count": "{assetCount, plural, other {#個の共有された写真と動画}}", "shared_with_partner": "{partner} と共有しました", @@ -1082,11 +1156,12 @@ "sharing_sidebar_description": "共有へのリンクをサイドバーに表示", "shift_to_permanent_delete": "⇧を押してアセットを完全に削除", "show_album_options": "アルバム設定を表示", + "show_albums": "アルバムを表示", "show_all_people": "全ての人物を表示", "show_and_hide_people": "人物を表示/非表示", "show_file_location": "ファイルの場所を表示", "show_gallery": "ギャラリーを表示", - "show_hidden_people": "", + "show_hidden_people": "非表示の人物を表示", "show_in_timeline": "タイムラインに表示", "show_in_timeline_setting_description": "このユーザーの写真と動画をタイムラインに表示", "show_keyboard_shortcuts": "キーボードショートカットを表示", @@ -1096,11 +1171,15 @@ "show_person_options": "人物設定を表示", "show_progress_bar": "プログレスバーを表示", "show_search_options": "検索オプションを表示", + "show_supporter_badge": "サポーターバッジ", + "show_supporter_badge_description": "サポーターバッジを表示", "shuffle": "ランダム", + "sidebar": "サイドバー", + "sidebar_display_description": "サイドバーにビューへのリンクを表示", "sign_out": "サインアウト", "sign_up": "登録", "size": "サイズ", - "skip_to_content": "", + "skip_to_content": "コンテンツへスキップ", "slideshow": "スライドショー", "slideshow_settings": "スライドショー設定", "sort_albums_by": "この順序でアルバムをソート…", @@ -1112,7 +1191,10 @@ "sort_title": "タイトル", "source": "ソース", "stack": "スタック", - "stack_selected_photos": "", + "stack_duplicates": "スタックの重複", + "stack_select_one_photo": "スタックのメインの写真を選択", + "stack_selected_photos": "選択した写真をスタックする", + "stacked_assets_count": "{count, plural, one {#枚} other {#枚}}スタックしました", "stacktrace": "スタックトレース", "start": "開始", "start_date": "開始日", @@ -1122,88 +1204,119 @@ "stop_photo_sharing": "写真の共有を無効化しますか?", "stop_photo_sharing_description": "{partner} はあなたの写真にアクセスできなくなります。", "stop_sharing_photos_with_user": "このユーザーとの写真の共有をやめる", - "storage": "ストレージ", + "storage": "ストレージの空き容量", "storage_label": "ストレージラベル", "storage_usage": "{available} 中 {used} 使用中", "submit": "送信", "suggestions": "ユーザーリスト", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "海岸の日の出", + "swap_merge_direction": "統合する方向を入れ替え", "sync": "同期", + "tag": "タグ付けする", + "tag_assets": "アセットにタグ付けする", + "tag_created": "タグ: {tag} を作成しました", + "tag_not_found_question": "タグが見つかりませんか? こちらからタグを作成できます", + "tag_updated": "タグ: {tag} を更新しました", + "tagged_assets": "{count, plural, one {#個のアセット} other {#個のアセット}}をタグ付けしました", + "tags": "タグ", "template": "テンプレート", "theme": "テーマ", "theme_selection": "テーマ選択", "theme_selection_description": "ブラウザのシステム設定に基づいてテーマを明色または暗色に自動的に設定します", + "they_will_be_merged_together": "これらは一緒に統合されます", "time_based_memories": "時間によるメモリー", "timezone": "タイムゾーン", "to_archive": "アーカイブ", "to_change_password": "パスワードを変更", "to_favorite": "お気に入り", "to_login": "ログイン", + "to_root": "最上層のフォルダへ", "to_trash": "ゴミ箱", "toggle_settings": "設定をトグル", - "toggle_theme": "テーマを切り替え", + "toggle_theme": "ダークテーマを切り替え", "toggle_visibility": "", "total_usage": "総使用量", "trash": "ゴミ箱", "trash_all": "全て削除", + "trash_count": "{count, number}枚ゴミ箱へ移動", "trash_delete_asset": "アセットをゴミ箱へ移動/削除", "trash_no_results_message": "ゴミ箱に移動した写真や動画がここに表示されます。", "trashed_items_will_be_permanently_deleted_after": "ゴミ箱に入れられたアイテムは{days, plural, one {#日} other {#日}}後に完全に削除されます。", "type": "タイプ", "unarchive": "アーカイブを解除", "unarchived": "", + "unarchived_count": "{count, plural, other {#枚アーカイブしました}}", "unfavorite": "お気に入りから外す", "unhide_person": "人物の非表示を解除", - "unknown": "", + "unknown": "不明", "unknown_album": "", - "unknown_year": "", - "unlink_oauth": "", - "unlinked_oauth_account": "", - "unselect_all": "", + "unknown_year": "不明な年", + "unlimited": "無制限", + "unlink_oauth": "OAuthのリンクを解除", + "unlinked_oauth_account": "リンクが解除されたOAuthアカウント", + "unnamed_album": "無名のアルバム", + "unnamed_album_delete_confirmation": "本当にこのアルバムを削除しますか?", + "unnamed_share": "無名の共有", + "unsaved_change": "未保存の変更", + "unselect_all": "全て選択解除", + "unselect_all_duplicates": "全ての重複の選択を解除", "unstack": "スタックを解除", + "unstacked_assets_count": "{count, plural, one {#個のアセット} other {#個のアセット}}をスタックから解除しました", + "untracked_files": "未追跡ファイル", "untracked_files_decription": "これらのファイルはアプリケーションによって追跡されていません。これらは移動の失敗、アップロードの中断、またはバグにより取り残されたものである可能性があります", - "up_next": "", - "updated_password": "", + "up_next": "次へ", + "updated_password": "パスワードを更新しました", "upload": "アップロード", "upload_concurrency": "アップロードの同時実行数", "upload_errors": "アップロードは{count, plural, one {#個} other {#個}}のエラーで完了しました、新しくアップロードされたアセットを見るにはページを更新してください。", - "upload_progress": "残り {remaining} - {processed}/{total} 処理済み", + "upload_progress": "残り {remaining, number} - {processed, number}/{total, number} 処理済み", "upload_skipped_duplicates": "{count, plural, one {#個} other {#個}}の重複アセットをスキップしました", "upload_status_duplicates": "重複", "upload_status_errors": "エラー", "upload_status_uploaded": "アップロード済", "upload_success": "アップロード成功、新しくアップロードされたアセットを見るにはページを更新してください。", - "url": "", - "usage": "", + "url": "URL", + "usage": "使用容量", "use_custom_date_range": "代わりにカスタム日付範囲を使用", - "user": "", + "user": "ユーザー", "user_id": "ユーザーID", "user_liked": "{user} が{type, select, photo {この写真を} video {この動画を} asset {このアセットを} other {}}いいねしました", + "user_purchase_settings": "購入", + "user_purchase_settings_description": "購入を管理", + "user_role_set": "{user} を{role}に設定しました", "user_usage_detail": "ユーザー使用状況の詳細", - "username": "", + "username": "ユーザー名", "users": "ユーザー", "utilities": "ユーティリティ", - "validate": "", - "variables": "", + "validate": "認証", + "variables": "変数", "version": "バージョン", "version_announcement_closing": "あなたの友人、Alex", + "version_announcement_message": "こんにちは、親愛なる皆様へ。アプリの新しいバージョンがありますので、構成の不整合を防ぐためにリリースノートにアクセスし、docker-compose.yml、及び.cnvの設定が最新か確認してください。特に自動的にアプリの更新を制御するWatchTowerやその他システムを利用している場合に当てはまります。", "video": "動画", "video_hover_setting": "ホバー時にサムネイルで動画を再生", "video_hover_setting_description": "マウスが項目の上にあるときに動画のサムネイルを再生します。無効時でも再生アイコンにカーソルを合わせると再生を開始できます。", "videos": "ビデオ", "videos_count": "{count, plural, one {#個} other {#個}}の動画", + "view": "見る", + "view_album": "アルバムを見る", "view_all": "すべて見る", - "view_all_users": "", - "view_links": "", - "view_next_asset": "", - "view_previous_asset": "", + "view_all_users": "全てのユーザーを確認する", + "view_in_timeline": "タイムラインで見る", + "view_links": "リンクを確認する", + "view_next_asset": "次のアセットを見る", + "view_previous_asset": "前のアセットを見る", + "view_stack": "ビュースタック", "viewer": "", "visibility_changed": "{count, plural, one {#人} other {#人}}の人物の非表示設定が変更されました", - "waiting": "", - "week": "", - "welcome_to_immich": "", - "year": "", + "waiting": "待機中", + "warning": "警告", + "week": "週", + "welcome": "ようこそ", + "welcome_to_immich": "immichにようこそ", + "year": "年", + "years_ago": "{years, plural, one {#年} other {#年}}前", "yes": "はい", + "you_dont_have_any_shared_links": "共有リンクはありません", "zoom_image": "画像を拡大" } diff --git a/web/src/lib/i18n/kmr.json b/i18n/kmr.json similarity index 99% rename from web/src/lib/i18n/kmr.json rename to i18n/kmr.json index e149e1c689..9d8bd29808 100644 --- a/web/src/lib/i18n/kmr.json +++ b/i18n/kmr.json @@ -1,7 +1,8 @@ { - "account": "", - "account_settings": "", - "acknowledge": "", + "about": "دەربارە", + "account": "هەژمار", + "account_settings": "ڕێکخستنی هەژمار", + "acknowledge": "دانپێدانان", "action": "", "actions": "", "active": "", @@ -176,7 +177,7 @@ "paths_validated_successfully": "", "quota_size_gib": "", "refreshing_all_libraries": "", - "removing_offline_files": "", + "removing_deleted_files": "", "repair_all": "", "repair_matched_items": "", "repaired_items": "", @@ -492,8 +493,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -726,10 +727,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "", "repair": "", diff --git a/web/src/lib/i18n/ko.json b/i18n/ko.json similarity index 84% rename from web/src/lib/i18n/ko.json rename to i18n/ko.json index 0ca6996e03..d3fd6c9022 100644 --- a/web/src/lib/i18n/ko.json +++ b/i18n/ko.json @@ -23,24 +23,26 @@ "add_to": "앨범에 추가...", "add_to_album": "앨범에 추가", "add_to_shared_album": "공유 앨범에 추가", - "added_to_archive": "보관함으로 이동되었습니다.", + "added_to_archive": "보관함에 추가되었습니다.", "added_to_favorites": "즐겨찾기에 추가되었습니다.", "added_to_favorites_count": "즐겨찾기에 항목 {count, number}개 추가됨", "admin": { - "add_exclusion_pattern_description": "규칙에 *, ** 및 ? 를 사용할 수 있습니다. \"Raw\" 디렉터리의 모든 파일을 제외하려면 **/Raw/**를, \".tif\"로 끝나는 파일을 제외하려면 **/*.tif를 사용합니다. 절대 경로는 /path/to/ignore/**처럼 사용하세요.", + "add_exclusion_pattern_description": "규칙에 *, ** 및 ? 를 사용할 수 있습니다. \"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": "백그라운드 작업", "check_all": "모두 확인", - "cleared_jobs": "{job} 작업 중단됨", - "config_set_by_file": "업로드한 설정 파일이 현재 설정에 적용됩니다.", + "cleared_jobs": "작업 중단: {job}", + "config_set_by_file": "현재 설정은 구성 파일에 의해 관리됩니다.", "confirm_delete_library": "{library} 라이브러리를 삭제하시겠습니까?", "confirm_delete_library_assets": "이 라이브러리를 삭제하시겠습니까? Immich에서 항목 {count, plural, one {#개} other {#개}}가 삭제되며 되돌릴 수 없습니다. 원본 파일은 삭제되지 않습니다.", "confirm_email_below": "계속 진행하려면 아래에 \"{email}\" 입력", "confirm_reprocess_all_faces": "모든 얼굴을 다시 처리하시겠습니까? 이름이 지정된 인물을 포함한 모든 인물이 삭제됩니다.", "confirm_user_password_reset": "{user}님의 비밀번호를 재설정하시겠습니까?", + "create_job": "작업 생성", "crontab_guru": "Crontab Guru", "disable_login": "로그인 비활성화", "disabled": "비활성화", @@ -49,27 +51,37 @@ "external_library_created_at": "외부 라이브러리 ({date}에 생성됨)", "external_library_management": "외부 라이브러리 관리", "face_detection": "얼굴 감지", - "face_detection_description": "기계 학습을 통해 항목에서 얼굴을 감지합니다. 동영상의 경우 섬네일만 사용합니다. \"모두\"는 이미 처리된 항목을 포함한 모든 항목을 대기열에 추가합니다. \"누락\"은 처리되지 않은 항목만 대기열에 추가합니다.", - "facial_recognition_job_description": "감지된 얼굴을 인물로 그룹화합니다. 얼굴 감지 작업이 완료된 후 진행되며, \"모두\"는 이미 그룹화된 얼굴을 포함한 모든 얼굴을 대기열에 추가합니다. \"누락\"은 그룹화되지 않은 얼굴만 대기열에 추가합니다.", + "face_detection_description": "기계 학습을 통해 항목에 존재하는 얼굴을 감지합니다. 동영상의 경우 섬네일만 사용합니다. \"새로고침\"은 이미 처리된 항목을 포함한 모든 항목 다시 처리합니다. \"초기화\"는 모든 얼굴 데이터를 삭제합니다. \"누락\"은 처리되지 않은 항목을 대기열에 추가합니다. 얼굴 감지 작업이 완료된 후 얼굴 인식 작업을 진행하여 얼굴을 기존 인물이나 새 인물로 그룹화합니다.", + "facial_recognition_job_description": "감지된 얼굴을 인물로 그룹화합니다. 이 작업은 얼굴 감지 작업이 완료된 후 진행됩니다. \"초기화\"는 모든 얼굴의 그룹화를 다시 진행합니다. \"누락\"은 그룹화가 완료되지 않은 얼굴을 대기열에 추가합니다.", "failed_job_command": "{job} 작업에서 {command} 실패", "force_delete_user_warning": "경고: 사용자 및 사용자가 업로드한 모든 항목이 즉시 삭제됩니다. 이 작업은 되돌릴 수 없으며 파일을 복구할 수 없습니다.", "forcing_refresh_library_files": "모든 파일을 다시 스캔하는 중...", + "image_format": "형식", "image_format_description": "WebP는 JPEG보다 파일 크기가 작지만 변환에 더 많은 시간이 소요됩니다.", "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_preview_description": "메타데이터를 제거한 중간 크기 이미지, 한장씩 볼때나 기계학습에 사용됨", "image_preview_format": "미리 보기 형식", + "image_preview_quality_description": "1부터 100 사이의 미리보기 품질. 값이 높을수록 좋지만 파일 크기가 커져 앱의 반응성이 떨어질 수 있습니다. 또한 값이 낮으면 기계 학습의 품질이 떨어질 수 있습니다.", "image_preview_resolution": "미리 보기 해상도", - "image_preview_resolution_description": "각 항목을 보거나 기계 학습에 사용되는 사진의 해상도를 설정합니다. 해상도가 높으면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.", + "image_preview_resolution_description": "사진을 보거나 기계 학습을 실행할 때 사용되는 사진의 해상도를 설정합니다. 높은 해상도를 선택하면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.", + "image_preview_title": "미리보기 설정", "image_quality": "품질", - "image_quality_description": "이미지 품질을 1에서 100 사이로 설정합니다. 품질이 높으면 파일 크기가 증가하지만 생성된 이미지의 품질이 향상됩니다. 이 옵션은 미리 보기 및 섬네일 이미지에 영향을 미칩니다.", + "image_quality_description": "이미지 품질을 1에서 100 사이로 설정합니다. 높은 품질을 선택하면 파일 크기가 증가하지만 생성된 이미지의 품질이 향상됩니다. 이 옵션은 미리 보기 및 섬네일 이미지에 영향을 미칩니다.", + "image_resolution": "해상도", + "image_resolution_description": "해상도가 높을 수록 디테일이 보존되지만 파일이 크고 인코딩이 오래 걸리며 앱 응답성이 떨어질 수 있습니다.", "image_settings": "이미지 설정", "image_settings_description": "생성된 이미지의 품질 및 해상도 관리", + "image_thumbnail_description": "메타데이터가 제거된 작은 섬네일 이미지, 타임라인 등 사진을 그룹화하여 보는 경우에 사용됨", "image_thumbnail_format": "섬네일 형식", + "image_thumbnail_quality_description": "섬네일 품질(1~100). 높을수록 좋지만 파일크기가 커져 앱의 반응성이 떨어질 수 있습니다.", "image_thumbnail_resolution": "섬네일 해상도", - "image_thumbnail_resolution_description": "여러 항목을 표시할 때 사용되는 사진의 해상도를 설정합니다. (메인 타임라인, 앨범 보기 등) 해상도가 높으면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.", + "image_thumbnail_resolution_description": "여러 항목을 표시할 때 사용되는 사진의 해상도를 설정합니다. (메인 타임라인, 앨범 보기 등) 높은 해상도를 선택하면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.", + "image_thumbnail_title": "섬네일 설정", "job_concurrency": "{job} 동시성", + "job_created": "작업이 생성되었습니다.", "job_not_concurrency_safe": "이 작업은 동시 실행이 제한됩니다.", "job_settings": "작업 설정", "job_settings_description": "작업 동시성 관리", @@ -95,7 +107,7 @@ "logging_level_description": "로깅이 활성화된 경우 사용할 로그 레벨을 선택합니다.", "logging_settings": "로깅", "machine_learning_clip_model": "CLIP 모델", - "machine_learning_clip_model_description": "CLIP 모델의 종류는 이곳을 참조하세요. 변경 후 모든 항목의 스마트 검색 작업을 다시 진행해야 합니다.", + "machine_learning_clip_model_description": "CLIP 모델의 종류는 이곳을 참조하세요. 한국어로 검색하려면 Multilingual CLIP 모델을 선택하세요. 변경 후 모든 항목에 대한 스마트 검색 작업을 다시 진행해야 합니다.", "machine_learning_duplicate_detection": "비슷한 항목 감지", "machine_learning_duplicate_detection_enabled": "비슷한 항목 감지 활성화", "machine_learning_duplicate_detection_enabled_description": "비활성화된 경우에도 완전히 일치하는 항목은 여전히 감지됩니다.", @@ -129,16 +141,21 @@ "map_enable_description": "지도 기능 활성화", "map_gps_settings": "지도 및 GPS 설정", "map_gps_settings_description": "지도 및 GPS (역지오코딩) 설정 관리", + "map_implications": "지도 기능은 외부 타일 서비스(tiles.immich.clou를 사용합니다.", "map_light_style": "라이트 스타일", "map_manage_reverse_geocoding_settings": "역지오코딩 설정 관리", "map_reverse_geocoding": "역지오코딩", "map_reverse_geocoding_enable_description": "역지오코딩 활성화", "map_reverse_geocoding_settings": "역지오코딩 설정", - "map_settings": "지도 설정", + "map_settings": "지도", "map_settings_description": "지도 설정 관리", "map_style_description": "지도 테마 style.json URL", "metadata_extraction_job": "메타데이터 추출", - "metadata_extraction_job_description": "각 항목에서 GPS, 해상도 등의 메타데이터 정보 추출", + "metadata_extraction_job_description": "각 항목에서 GPS, 인물 및 해상도 등의 메타데이터 정보 추출", + "metadata_faces_import_setting": "얼굴 가져오기 활성화", + "metadata_faces_import_setting_description": "사이드카 파일의 이미지 EXIF 데이터에서 얼굴 가져오기", + "metadata_settings": "메타데이터 설정", + "metadata_settings_description": "메타데이터 설정 관리", "migration_job": "마이그레이션", "migration_job_description": "각 항목의 섬네일 및 인물의 얼굴을 최신 폴더 구조로 마이그레이션", "no_paths_added": "추가된 경로 없음", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "주의: 추후 변경할 수 없습니다!", "note_unlimited_quota": "참고: 할당량을 설정하지 않으려면 0을 입력하세요.", "notification_email_from_address": "보낸 사람 이메일", - "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", + "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", "notification_email_host_description": "이메일 서버의 호스트 (예: smtp.immich.app)", "notification_email_ignore_certificate_errors": "인증서 오류 무시", "notification_email_ignore_certificate_errors_description": "TLS 인증서 유효성 검사 오류 무시 (권장되지 않음)", @@ -173,13 +190,13 @@ "oauth_issuer_url": "발급자 URL", "oauth_mobile_redirect_uri": "모바일 리다이렉트 URI", "oauth_mobile_redirect_uri_override": "모바일 리다이렉트 URI 재정의", - "oauth_mobile_redirect_uri_override_description": "'app.immich:/'가 잘못된 리다이렉트 URI인 경우 활성화하세요.", + "oauth_mobile_redirect_uri_override_description": "OAuth 공급자가 '{callback}'과 같은 모바일 URI를 제공하지 않는 경우 활성화하세요.", "oauth_profile_signing_algorithm": "사용자 정보 서명 알고리즘", "oauth_profile_signing_algorithm_description": "사용자 정보 서명에 사용되는 알고리즘을 선택합니다.", "oauth_scope": "스코프", "oauth_settings": "OAuth", "oauth_settings_description": "OAuth 로그인 설정 관리", - "oauth_settings_more_details": "이 기능에 대한 자세한 내용은 공식 문서를 참조하세요.", + "oauth_settings_more_details": "이 기능에 대한 자세한 내용은 문서를 참조하세요.", "oauth_signing_algorithm": "서명 알고리즘", "oauth_storage_label_claim": "스토리지 레이블 선택", "oauth_storage_label_claim_description": "스토리지 레이블을 사용자가 입력한 값으로 자동 설정합니다.", @@ -193,19 +210,22 @@ "password_settings": "비밀번호 로그인", "password_settings_description": "비밀번호 로그인 설정 관리", "paths_validated_successfully": "모든 경로를 성공적으로 검증했습니다.", + "person_cleanup_job": "인물 정리", "quota_size_gib": "할당량 (GiB)", "refreshing_all_libraries": "모든 라이브러리 다시 스캔 중...", "registration": "관리자 가입", "registration_description": "첫 번째 사용자이기 때문에 관리자로 지정되었습니다. 관리 작업 및 사용자 생성이 가능합니다.", - "removing_offline_files": "누락된 파일을 제거하는 중...", + "removing_deleted_files": "누락된 파일을 제거하는 중...", "repair_all": "모두 수리", "repair_matched_items": "동일한 항목 {count, plural, one {#개} other {#개}}를 확인했습니다.", "repaired_items": "항목 {count, plural, one {#개} other {#개}}를 수리했습니다.", "require_password_change_on_login": "첫 로그인 시 비밀번호 변경 요구", "reset_settings_to_default": "설정을 기본값으로 복원", "reset_settings_to_recent_saved": "마지막으로 저장된 설정으로 복원", + "scanning_library": "라이브러리 스캔 중", "scanning_library_for_changed_files": "라이브러리 변경 사항 확인 중...", "scanning_library_for_new_files": "라이브러리에서 새 파일 스캔 중...", + "search_jobs": "작업 검색...", "send_welcome_email": "환영 이메일 전송", "server_external_domain_settings": "외부 도메인", "server_external_domain_settings_description": "공개 공유 링크에 사용할 도메인 (http(s):// 포함)", @@ -233,6 +253,7 @@ "storage_template_settings_description": "업로드된 항목의 폴더 구조 및 파일 이름 관리", "storage_template_user_label": "사용자의 스토리지 레이블: {label}", "system_settings": "시스템 설정", + "tag_cleanup_job": "태그 정리", "theme_custom_css_settings": "사용자 정의 CSS", "theme_custom_css_settings_description": "Immich에 적용할 사용자 정의 CSS(Cascading Style Sheets) 설정", "theme_settings": "테마 설정", @@ -261,31 +282,31 @@ "transcoding_constant_quality_mode": "Constant quality mode", "transcoding_constant_quality_mode_description": "ICQ는 CQP보다 나은 성능을 보이나 일부 기기의 하드웨어 가속에서 지원되지 않을 수 있습니다. 이 옵션을 설정하면 품질 기반 인코딩 시 지정된 모드를 우선적으로 사용합니다. NVENC에서는 ICQ를 지원하지 않아 이 설정이 적용되지 않습니다.", "transcoding_constant_rate_factor": "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_hardware_acceleration": "하드웨어 가속", "transcoding_hardware_acceleration_description": "실험적인 기능입니다. 속도가 향상되지만 동일 비트레이트에서 품질이 상대적으로 낮을 수 있습니다.", "transcoding_hardware_decoding": "하드웨어 디코딩", - "transcoding_hardware_decoding_setting_description": "인코딩 가속을 위해 엔드 투 엔드 가속을 사용합니다. 모든 동영상에서 작동하지 않을 수 있습니다. (NVENC, QSV 및 RKMPP만 해당)", + "transcoding_hardware_decoding_setting_description": "인코딩 가속을 위해 엔드 투 엔드 가속을 사용합니다. 모든 동영상에서 작동하지 않을 수 있습니다.", "transcoding_hevc_codec": "HEVC 코덱", "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는 2600k, H.264는 4500k를 사용합니다. 0을 입력한 경우 비활성화됩니다.", "transcoding_max_keyframe_interval": "최대 키프레임 간격", "transcoding_max_keyframe_interval_description": "키프레임 사이 최대 프레임 거리를 설정합니다. 값이 낮으면 압축 효율이 저하되지만 검색 시간이 개선되고 빠른 움직임이 있는 장면에서 품질이 향상됩니다. 0을 입력한 경우 자동으로 설정합니다.", "transcoding_optimal_description": "목표 해상도보다 높은 동영상 또는 허용되지 않는 형식의 동영상", "transcoding_preferred_hardware_device": "선호하는 하드웨어 기기", "transcoding_preferred_hardware_device_description": "하드웨어 트랜스코딩에 사용할 dri 노드를 설정합니다. (VAAPI와 QSV만 해당)", "transcoding_preset_preset": "프리셋 (-preset)", - "transcoding_preset_preset_description": "압축 속도를 설정합니다. 느린 프리셋을 선택하면 파일 크기가 감소하고 목표 비트레이트를 지정한 경우 품질이 향상됩니다. VP9의 경우 `faster` 이상의 속도가 적용되지 않습니다.", + "transcoding_preset_preset_description": "압축 속도를 설정합니다. 동일 비트레이트 기준에서 느린 속도를 선택하면 파일 크기가 감소하고 품질이 향상됩니다. VP9는 'faster' 이상의 속도가 적용되지 않습니다.", "transcoding_reference_frames": "참조 프레임", "transcoding_reference_frames_description": "특정 프레임을 압축할 때 참조하는 프레임 수를 설정합니다. 값이 높으면 압축 효율이 향상되나 인코딩 속도가 저하됩니다. 0을 입력한 경우 자동으로 설정합니다.", "transcoding_required_description": "허용된 형식이 아닌 동영상만", "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": "스레드", @@ -307,6 +328,7 @@ "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": "사용자를 영구적으로 삭제하기 전 보류 기간을 설정합니다. 사용자 삭제는 매일 밤 자정, 보류 기간이 지난 사용자를 확인한 후 진행됩니다. 변경 사항은 다음 작업부터 적용됩니다.", @@ -320,7 +342,8 @@ "user_settings": "사용자 설정", "user_settings_description": "사용자 설정 관리", "user_successfully_removed": "{email}이(가) 성공적으로 제거되었습니다.", - "version_check_enabled_description": "최신 버전 확인을 위한 주기적인 GitHub 확인 활성화", + "version_check_enabled_description": "버전 확인 활성화", + "version_check_implications": "주기적으로 github.com에 요청을 보내 최신 버전을 확인합니다.", "version_check_settings": "버전 확인", "version_check_settings_description": "최신 버전 알림 설정 관리", "video_conversion_job": "동영상 트랜스코드", @@ -335,9 +358,10 @@ "age_years": "{years, plural, other {#세}}", "album_added": "공유 앨범 초대", "album_added_notification_setting_description": "공유 앨범으로 초대를 받은 경우 이메일 알림 받기", - "album_cover_updated": "앨범 커버를 변경했습니다.", - "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?\n이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", - "album_info_updated": "앨범 정보가 수정되었습니다.", + "album_cover_updated": "앨범 커버 업데이트됨", + "album_delete_confirmation": "{album} 앨범을 삭제하시겠습니까?", + "album_delete_confirmation_description": "이 앨범을 공유한 경우 다른 사용자가 더 이상 앨범에 접근할 수 없습니다.", + "album_info_updated": "앨범 정보 업데이트됨", "album_leave": "앨범에서 나가시겠습니까?", "album_leave_confirmation": "{album} 앨범에서 나가시겠습니까?", "album_name": "앨범 이름", @@ -347,8 +371,8 @@ "album_share_no_users": "이미 모든 사용자와 앨범을 공유 중이거나 다른 사용자가 없는 것 같습니다.", "album_updated": "항목 추가 알림", "album_updated_setting_description": "공유 앨범에 항목이 추가된 경우 이메일 알림 받기", - "album_user_left": "{album} 앨범에서 나왔습니다.", - "album_user_removed": "{user}님을 앨범에서 제거했습니다.", + "album_user_left": "{album} 앨범에서 나옴", + "album_user_removed": "{user}님을 앨범에서 제거함", "album_with_link_access": "링크가 있는 경우 누구나 이 앨범의 사진과 인물을 볼 수 있습니다.", "albums": "앨범", "albums_count": "앨범 {count, plural, one {{count, number}개} other {{count, number}개}}", @@ -360,6 +384,7 @@ "allow_edits": "편집자로 설정", "allow_public_user_to_download": "모든 사용자의 다운로드 허용", "allow_public_user_to_upload": "모든 사용자의 업로드 허용", + "anti_clockwise": "반시계 방향", "api_key": "API 키", "api_key_description": "이 값은 한 번만 표시됩니다. 창을 닫기 전 반드시 복사하세요.", "api_key_empty": "키 이름은 비어 있을 수 없습니다.", @@ -376,26 +401,27 @@ "are_you_sure_to_do_this": "계속 진행하시겠습니까?", "asset_added_to_album": "앨범에 추가되었습니다.", "asset_adding_to_album": "앨범에 추가 중...", - "asset_description_updated": "설명이 변경되었습니다.", - "asset_filename_is_offline": "{filename} 항목이 누락되었습니다.", - "asset_has_unassigned_faces": "항목에 알 수 없는 인물이 있습니다.", + "asset_description_updated": "항목의 설명이 업데이트되었습니다.", + "asset_filename_is_offline": "{filename} 항목 누락됨", + "asset_has_unassigned_faces": "항목에 할당되지 않은 얼굴이 있음", "asset_hashing": "해시 확인 중...", "asset_offline": "누락된 항목", - "asset_offline_description": "이 항목은 누락되었습니다. Immich가 파일 위치에 접근할 수 없습니다. 해당 위치에 접근이 가능하거나 파일이 존재하는지 확인한 뒤 라이브러리를 다시 스캔하세요.", + "asset_offline_description": "디스크에서 항목을 더이상 찾을 수 없습니다. 서버 관리자에게 연락하여 도움을 받으세요.", "asset_skipped": "건너뜀", + "asset_skipped_in_trash": "휴지통의 항목", "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, one {#개} other {#개}} 추가됨", "assets_added_to_name_count": "{hasName, select, true {{name}} other {새 앨범}}에 항목 {count, plural, one {#개} other {#개}} 추가됨", "assets_count": "{count, plural, one {#개} other {#개}} 항목", "assets_moved_to_trash": "항목 {count, plural, one {#개} other {#개}}를 휴지통으로 이동함", "assets_moved_to_trash_count": "휴지통으로 항목 {count, plural, one {#개} other {#개}} 이동됨", "assets_permanently_deleted_count": "항목 {count, plural, one {#개} other {#개}}가 영구적으로 삭제됨", - "assets_removed_count": "항목 {count, plural, one {#개} other {#개}} 제거됨", - "assets_restore_confirmation": "휴지통으로 이동된 항목을 모두 복원하시겠습니까? 이 작업은 되돌릴 수 없습니다!", - "assets_restored_count": "항목 {count, plural, one {#개} other {#개}} 복원됨", + "assets_removed_count": "항목 {count, plural, one {#개} other {#개}}를 제거했습니다.", + "assets_restore_confirmation": "휴지통으로 이동된 항목을 모두 복원하시겠습니까? 이 작업은 되돌릴 수 없습니다! 누락된 항목의 경우 복원되지 않습니다.", + "assets_restored_count": "항목 {count, plural, one {#개} other {#개}}를 복원했습니다.", "assets_trashed_count": "휴지통으로 항목 {count, plural, one {#개} other {#개}} 이동됨", "assets_were_part_of_album_count": "앨범에 이미 존재하는 {count, plural, one {항목} other {항목}}입니다.", "authorized_devices": "인증된 기기", @@ -405,6 +431,7 @@ "birthdate_saved": "생년월일이 성공적으로 저장되었습니다.", "birthdate_set_description": "생년월일은 사진 촬영 당시 인물의 나이를 계산하는 데 사용됩니다.", "blurred_background": "흐린 배경", + "bugs_and_feature_requests": "버그 제보 & 기능 요청", "build": "빌드", "build_image": "빌드 이미지", "bulk_delete_duplicates_confirmation": "비슷한 항목 {count, plural, one {#개} other {#개}}를 삭제하시겠습니까? 크기가 가장 큰 항목을 제외한 나머지 항목들이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다!", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "검색 기록 전체 삭제", "clear_message": "메시지 지우기", "clear_value": "값 지우기", + "clockwise": "시계 방향", "close": "닫기", "collapse": "접기", "collapse_all": "모두 접기", + "color": "색상", "color_theme": "테마 색상", "comment_deleted": "댓글이 삭제되었습니다.", "comment_options": "댓글 옵션", @@ -477,6 +506,8 @@ "create_new_person": "인물 생성", "create_new_person_hint": "선택한 항목의 인물을 새 인물로 변경", "create_new_user": "사용자 생성", + "create_tag": "태그 생성", + "create_tag_description": "새 태그를 생성합니다. 하위 태그의 경우 /를 포함한 전체 태그명을 입력하세요.", "create_user": "사용자 생성", "created": "생성됨", "current_device": "현재 기기", @@ -489,7 +520,7 @@ "date_of_birth_saved": "생년월일이 성공적으로 저장되었습니다.", "date_range": "날짜 범위", "day": "일", - "deduplicate_all": "비슷한 항목 모두 선택", + "deduplicate_all": "모두 삭제", "default_locale": "기본 로케일", "default_locale_description": "브라우저 로케일에 따른 날짜 및 숫자 형식 지정", "delete": "삭제", @@ -500,13 +531,17 @@ "delete_library": "라이브러리 삭제", "delete_link": "링크 삭제", "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": "오류 무시", @@ -515,8 +550,11 @@ "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": "다운로드", @@ -546,10 +584,15 @@ "edit_location": "위치 변경", "edit_name": "이름 변경", "edit_people": "인물 변경", + "edit_tag": "태그 편집", "edit_title": "제목 변경", "edit_user": "사용자 수정", - "edited": "펀집되었습니다.", + "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": "", "empty_album": "", @@ -559,19 +602,19 @@ "enabled": "활성화됨", "end_date": "종료일", "error": "오류", - "error_loading_image": "사진을 불러오는 중 문제가 발생했습니다.", + "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_asset_favorite": "즐겨찾기 상태를 변경할 수 없습니다.", + "cant_change_asset_favorite": "즐겨찾기를 변경할 수 없습니다.", "cant_change_metadata_assets_count": "항목 {count, plural, one {#개} other {#개}}의 메타데이터를 변경할 수 없습니다.", - "cant_get_faces": "얼굴을 불러올 수 없습니다.", - "cant_get_number_of_comments": "댓글의 개수를 불러올 수 없습니다.", - "cant_search_people": "인물을 검색할 수 없습니다.", - "cant_search_places": "장소를 검색할 수 없습니다.", + "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": "앨범에 사용자를 추가하는 중 문제가 발생했습니다.", @@ -616,7 +659,7 @@ "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "OAuth 로그인을 완료할 수 없습니다.", - "unable_to_connect": "연결할 수 없습니다.", + "unable_to_connect": "연결할 수 없음", "unable_to_connect_to_server": "서버에 연결할 수 없습니다.", "unable_to_copy_to_clipboard": "클립보드에 복사할 수 없습니다. https를 통해 접속 중인지 확인하세요.", "unable_to_create_admin_account": "관리자 계정을 생성할 수 없습니다.", @@ -639,6 +682,7 @@ "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": "사용자의 반응을 불러올 수 없습니다.", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "API 키를 삭제할 수 없습니다.", "unable_to_remove_assets_from_shared_link": "공유 링크에서 항목을 제거할 수 없습니다.", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "누락된 파일을 제거할 수 없습니다.", "unable_to_remove_library": "라이브러리를 제거할 수 없습니다.", - "unable_to_remove_offline_files": "누락된 파일을 제거할 수 없습니다.", "unable_to_remove_partner": "파트너를 제거할 수 없습니다.", "unable_to_remove_reaction": "반응을 제거할 수 없습니다.", "unable_to_remove_user": "", @@ -664,7 +708,7 @@ "unable_to_reset_password": "비밀번호를 초기화할 수 없습니다.", "unable_to_resolve_duplicate": "비슷한 항목을 처리할 수 없습니다.", "unable_to_restore_assets": "항목을 복원할 수 없습니다.", - "unable_to_restore_trash": "휴지통에서 항목을 복원할 수 없습니다.", + "unable_to_restore_trash": "휴지통을 복원할 수 없습니다.", "unable_to_restore_user": "사용자 삭제를 취소할 수 없습니다.", "unable_to_save_album": "앨범을 저장할 수 없습니다.", "unable_to_save_api_key": "API 키를 수정할 수 없습니다.", @@ -677,8 +721,9 @@ "unable_to_set_feature_photo": "대표 사진을 지정할 수 없습니다.", "unable_to_set_profile_picture": "프로필 사진을 설정할 수 없습니다.", "unable_to_submit_job": "작업을 수행할 수 없습니다.", - "unable_to_trash_asset": "휴지통으로 항목을 이동할 수 없습니다.", + "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": "라이브러리를 업데이트할 수 없습니다.", @@ -699,6 +744,7 @@ "expired": "만료됨", "expires_date": "{date} 만료", "explore": "탐색", + "explorer": "탐색기", "export": "내보내기", "export_as_json": "JSON으로 내보내기", "extension": "확장자", @@ -710,8 +756,10 @@ "favorite_or_unfavorite_photo": "즐겨찾기 추가 및 제거", "favorites": "즐겨찾기", "feature": "", - "feature_photo_updated": "대표 사진이 설정되었습니다.", + "feature_photo_updated": "대표 사진 업데이트됨", "featurecollection": "", + "features": "기능", + "features_setting_description": "앱 기능 관리", "file_name": "파일 이름", "file_name_or_extension": "파일명 또는 확장자", "filename": "파일명", @@ -720,6 +768,8 @@ "filter_people": "인물 필터", "find_them_fast": "이름으로 검색하여 빠르게 찾기", "fix_incorrect_match": "잘못된 분류 수정", + "folders": "폴더", + "folders_feature_description": "파일 시스템의 사진 및 동영상을 폴더 뷰로 탐색", "force_re-scan_library_files": "모든 파일 강제 다시 스캔", "forward": "앞으로", "general": "일반", @@ -743,12 +793,16 @@ "host": "호스트", "hour": "시간", "image": "이미지", - "image_alt_text_date": "{date}에 촬영된 {isVideo, select, true {동영상} other {사진}}", - "image_alt_text_date_1_person": "{date}에 {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}", - "image_alt_text_date_2_people": "{date}에 {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}", - "image_alt_text_date_3_people": "{date}에 {person1}, {person2}, {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}", - "image_alt_text_date_4_or_more_people": "{date}에 {person1}, {person2}, 그 외 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}", - "image_alt_text_date_place": "{city}, {country}에서 {date}에 촬영한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date": "{date} 촬영한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_1_person": "{date} {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_2_people": "{date} {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_3_people": "{date} {person1}, {person2}, {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_4_or_more_people": "{date} {person1}, {person2}님 및 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_place": "{date} {country}, {city}에서 촬영한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_place_1_person": "{date} {country}, {city}에서 {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_place_2_people": "{date} {country}, {city}에서 {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_place_3_people": "{date} {country}, {city}에서 {person1}, {person2}님 및 {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}", + "image_alt_text_date_place_4_or_more_people": "{date} {country}, {city}에서 {person1}, {person2}님 및 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}", "image_alt_text_people": "{count, plural, =1 {{person1}님과 함께,} =2 {{person1} 및 {person2}님과 함께,} =3 {{person1}, {person2} 및 {person3}님과 함께,} other {{person1}, {person2}, 및 {others, number}명과 함께,}}", "image_alt_text_place": "{country}, {city}에서", "image_taken": "{isVideo, select, true {동영상} other {사진}},", @@ -798,6 +852,7 @@ "license_failed_activation": "라이선스를 활성화하지 못했습니다. 이메일로 발송된 키를 정확히 입력했는지 확인하세요!", "light": "라이트", "like_deleted": "좋아요가 삭제되었습니다.", + "link_motion_video": "모션 비디오 링크", "link_options": "링크 옵션", "link_to_oauth": "OAuth에 연결", "linked_oauth_account": "OAuth 계정이 연결되었습니다.", @@ -816,6 +871,7 @@ "look": "보기", "loop_videos": "동영상 반복", "loop_videos_description": "상세 보기에서 동영상을 자동으로 반복 재생합니다.", + "main_branch_warning": "현재 개발 버전을 사용 중입니다. 정식 버전을 사용하는 것을 강력히 권장합니다!", "make": "제조사", "manage_shared_links": "공유 링크 관리", "manage_sharing_with_partners": "파트너와 공유 관리", @@ -885,12 +941,14 @@ "notifications": "알림", "notifications_setting_description": "알림 설정 관리", "oauth": "OAuth", + "official_immich_resources": "Immich 공식 리소스", "offline": "오프라인", "offline_paths": "누락된 파일", "offline_paths_description": "외부 라이브러리의 항목이 아닌 파일을 수동으로 삭제한 경우 발생할 수 있습니다.", "ok": "확인", "oldest_first": "오래된 순", "onboarding": "온보딩", + "onboarding_privacy_description": "이 선택적 기능은 외부 서비스를 사용하며, 관리자 설정에서 언제든 비활성화할 수 있습니다.", "onboarding_storage_template_description": "활성화한 경우, 사용자 정의 템플릿을 기반으로 파일을 자동 분류합니다. 안정성 문제로 인해 해당 기능은 기본적으로 비활성화 되어 있습니다. 자세한 내용은 [공식 문서]를 참조하세요.", "onboarding_theme_description": "색상 테마를 선택하세요. 나중에 설정에서 변경할 수 있습니다.", "onboarding_welcome_description": "몇 가지 일반적인 설정을 진행하겠습니다.", @@ -898,6 +956,7 @@ "online": "온라인", "only_favorites": "즐겨찾기만 표시", "only_refreshes_modified_files": "변경된 파일만 다시 스캔", + "open_in_map_view": "지도 뷰에서 보기", "open_in_openstreetmap": "OpenStreetMap에서 열기", "open_the_search_filters": "검색 필터 열기", "options": "옵션", @@ -932,6 +991,7 @@ "pending": "진행 중", "people": "인물", "people_edits_count": "인물 {count, plural, one {#명} other {#명}}을 변경했습니다.", + "people_feature_description": "사진 및 동영상을 인물 그룹별로 탐색", "people_sidebar_description": "사이드바에 인물 링크 표시", "perform_library_tasks": "", "permanent_deletion_warning": "영구 삭제 경고", @@ -963,32 +1023,33 @@ "previous": "이전", "previous_memory": "이전 추억", "previous_or_next_photo": "이전 또는 다음 이미지로", - "primary": "", + "primary": "주요", + "privacy": "프라이버시", "profile_image_of_user": "{user}님의 프로필 이미지", "profile_picture_set": "프로필 사진이 설정되었습니다.", "public_album": "공개 앨범", "public_share": "모든 사용자와 공유", "purchase_account_info": "서포터", "purchase_activated_subtitle": "Immich와 오픈 소스 소프트웨어를 지원해주셔서 감사합니다.", - "purchase_activated_time": "{date, date}에 활성화됨", - "purchase_activated_title": "제품 키가 성공적으로 활성화되었습니다.", - "purchase_button_activate": "활성화", + "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_failed_activation": "등록하지 못했습니다. 이메일로 전송된 키를 정확히 입력했는지 확인하세요!", "purchase_individual_description_1": "개인 사용자용", - "purchase_individual_description_2": "서포터 현황", + "purchase_individual_description_2": "서포터 배지 및 표시", "purchase_individual_title": "개인", - "purchase_input_suggestion": "제품 키를 보유 중인가요? 아래에 제품 키를 입력하세요.", + "purchase_input_suggestion": "제품 키를 보유하고 있나요? 아래에 제품 키를 입력하세요.", "purchase_license_subtitle": "Immich를 구매하여 지속적인 개발에 도움을 주세요.", "purchase_lifetime_description": "일회성 구매", "purchase_option_title": "구매 옵션", "purchase_panel_info_1": "Immich를 개발하는 데는 많은 시간과 노력이 필요합니다. 우리는 좋은 앱을 만들기 위해 풀 타임 개발자와 함께하고 있으며, 최종적으로 오픈 소스 소프트웨어와 비즈니스 행동 윤리가 개발자에게 지속 가능한 수입원을 제공하고 착취적인 클라우드 서비스를 대체할 수 있는 개인 정보 보호 생태계를 구축하는 것을 원합니다.", - "purchase_panel_info_2": "유료 기능을 추가하지 않기로 약속했기에, 이 구매는 어떠한 추가 기능도 제공하지 않습니다. 우리는 Immich의 지속적인 개발을 지원하는 사용자 여러분에게 의존하고 있습니다.", + "purchase_panel_info_2": "유료 기능을 추가하지 않기로 약속했기에 이 구매는 어떠한 추가 기능도 제공하지 않습니다. 우리는 Immich의 지속적인 개발을 지원하는 사용자 여러분에게 의존하고 있습니다.", "purchase_panel_title": "프로젝트 지원", "purchase_per_server": "서버당", "purchase_per_user": "사용자당", @@ -996,11 +1057,15 @@ "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_description_1": "서버 전체에 적용", + "purchase_server_description_2": "서포터 배지 및 표시", "purchase_server_title": "서버", "purchase_settings_server_activated": "서버 제품 키는 관리자가 관리합니다.", "range": "", + "rating": "등급", + "rating_clear": "등급 초기화", + "rating_count": "{count, plural, one {#점} other {#점}}", + "rating_description": "상세 정보에 EXIF의 등급 정보 표시", "raw": "", "reaction_options": "반응 옵션", "read_changelog": "변경 사항 보기", @@ -1012,11 +1077,13 @@ "recent_searches": "최근 검색", "refresh": "새로고침", "refresh_encoded_videos": "동영상 재인코딩", + "refresh_faces": "얼굴 새로고침", "refresh_metadata": "메타데이터 갱신", "refresh_thumbnails": "섬네일 다시 생성", "refreshed": "새로고침이 완료되었습니다.", - "refreshes_every_file": "모든 파일을 다시 스캔", + "refreshes_every_file": "기존 파일 및 새 파일 스캔", "refreshing_encoded_video": "인코딩을 다시 진행하는 중...", + "refreshing_faces": "얼굴 새로고침 중", "refreshing_metadata": "메타데이터를 갱신하는 중...", "regenerating_thumbnails": "섬네일을 다시 생성하는 중...", "remove": "제거", @@ -1024,15 +1091,16 @@ "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": "공유 링크에서 제거", - "remove_offline_files": "누락된 파일 제거", "remove_user": "사용자 삭제", "removed_api_key": "API 키 삭제: {name}", "removed_from_archive": "보관함에서 제거되었습니다.", "removed_from_favorites": "즐겨찾기에서 제거되었습니다.", - "removed_from_favorites_count": "즐겨찾기에서 항목 {count, plural, other {#개}}가 제거되었습니다.", + "removed_from_favorites_count": "즐겨찾기에서 항목 {count, plural, other {#개}} 제거됨", + "removed_tagged_assets": "항목 {count, plural, one {#개} other {#개}}에서 태그를 제거함", "rename": "이름 바꾸기", "repair": "수리", "repair_no_results_message": "추적되지 않거나 누락된 파일이 이곳에 표시됩니다.", @@ -1045,7 +1113,7 @@ "reset_people_visibility": "인물 숨김 여부 초기화", "reset_settings_to_default": "", "reset_to_default": "기본값으로 복원", - "resolve_duplicates": "중복 해결", + "resolve_duplicates": "비슷한 항목 확인", "resolved_all_duplicates": "비슷한 항목을 모두 확인했습니다.", "restore": "복원", "restore_all": "모두 복원", @@ -1064,6 +1132,7 @@ "say_something": "댓글을 입력하세요", "scan_all_libraries": "모든 라이브러리 스캔", "scan_all_library_files": "모든 파일 다시 스캔", + "scan_library": "스캔", "scan_new_library_files": "새 라이브러리 파일 스캔", "scan_settings": "스캔 설정", "scanning_for_album": "앨범을 스캔하는 중...", @@ -1079,9 +1148,12 @@ "search_for_existing_person": "존재하는 인물 검색", "search_no_people": "인물이 없습니다.", "search_no_people_named": "\"{name}\" 인물을 찾을 수 없음", + "search_options": "검색 옵션", "search_people": "인물 검색", "search_places": "장소 검색", + "search_settings": "설정 검색", "search_state": "지역 검색...", + "search_tags": "태그로 검색...", "search_timezone": "시간대 검색...", "search_type": "검색 종류", "search_your_photos": "사진 검색", @@ -1090,7 +1162,7 @@ "see_all_people": "모든 인물 보기", "select_album_cover": "앨범 커버 변경", "select_all": "모두 선택", - "select_all_duplicates": "중복 모두 선택", + "select_all_duplicates": "모두 선택", "select_avatar_color": "프로필 색상 변경", "select_face": "얼굴 선택", "select_featured_photo": "대표 사진 선택", @@ -1101,7 +1173,7 @@ "select_photos": "사진 선택", "select_trash_all": "모두 삭제", "selected": "선택됨", - "selected_count": "{count, plural, other {#개}} 선택됨", + "selected_count": "{count, plural, other {#개}} 항목 선택됨", "send_message": "메시지 전송", "send_welcome_email": "환영 이메일 전송", "server": "서버", @@ -1123,6 +1195,7 @@ "shared_by_user": "{user}님이 공유함", "shared_by_you": "내가 공유함", "shared_from_partner": "{partner}님의 사진", + "shared_link_options": "공유 링크 옵션", "shared_links": "공유 링크", "shared_photos_and_videos_count": "사진 및 동영상 {assetCount, plural, other {#개를 공유했습니다.}}", "shared_with_partner": "{partner}님과 공유함", @@ -1131,6 +1204,7 @@ "sharing_sidebar_description": "사이드바에 공유 링크 표시", "shift_to_permanent_delete": "⇧를 눌러 항목을 영구적으로 삭제", "show_album_options": "앨범 옵션 표시", + "show_albums": "앨범 표시", "show_all_people": "모든 인물 보기", "show_and_hide_people": "인물 숨기기", "show_file_location": "파일 위치 표시", @@ -1145,13 +1219,18 @@ "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": "다음으로 앨범 정렬...", @@ -1163,8 +1242,10 @@ "sort_title": "제목", "source": "소스", "stack": "스택", + "stack_duplicates": "비슷한 항목 스택", + "stack_select_one_photo": "스택의 대표 사진 선택", "stack_selected_photos": "선택한 이미지 스택", - "stacked_assets_count": "항목 {count, plural, one {#개} other {#개}}의 스택을 만들었습니다.", + "stacked_assets_count": "항목 {count, plural, one {#개} other {#개}} 스택됨", "stacktrace": "스택 추적", "start": "시작", "start_date": "시작일", @@ -1180,27 +1261,41 @@ "submit": "확인", "suggestions": "추천", "sunrise_on_the_beach": "동해안에서 맞이하는 새해 일출", + "support": "지원", + "support_and_feedback": "지원 & 제안", + "support_third_party_description": "Immich가 서드파티 패키지로 설치 되었습니다. 링크를 눌러 먼저 패키지 문제인지 확인해 보세요.", "swap_merge_direction": "병합 방향 변경", "sync": "동기화", + "tag": "태그", + "tag_assets": "항목 태그", + "tag_created": "태그 생성됨: {tag}", + "tag_feature_description": "사진 및 동영상을 주제별 그룹화된 태그로 탐색", + "tag_not_found_question": "태그를 찾을 수 없나요? 새 태그를 생성하세요.", + "tag_updated": "태그 업데이트됨: {tag}", + "tagged_assets": "항목 {count, plural, one {#개} other {#개}}에 태그를 적용함", + "tags": "태그", "template": "템플릿", "theme": "테마", "theme_selection": "테마 설정", "theme_selection_description": "브라우저 및 시스템 기본 설정에 따라 라이트 모드와 다크 모드를 자동으로 설정", "they_will_be_merged_together": "선택한 인물들이 병합됩니다.", + "third_party_resources": "서드 파티 리소스", "time_based_memories": "시간 기준 추억", "timezone": "시간대", "to_archive": "보관함으로 이동", "to_change_password": "비밀번호 변경", "to_favorite": "즐겨찾기", "to_login": "로그인", - "to_trash": "휴지통", + "to_parent": "상위 항목으로", + "to_root": "루트", + "to_trash": "삭제", "toggle_settings": "설정 변경", - "toggle_theme": "테마 변경", + "toggle_theme": "다크 모드 사용", "toggle_visibility": "숨김 여부 변경", "total_usage": "총 사용량", "trash": "휴지통", "trash_all": "모두 삭제", - "trash_count": "휴지통으로 이동 ({count, number}개)", + "trash_count": "{count, number}개 삭제", "trash_delete_asset": "휴지통 이동/삭제", "trash_no_results_message": "휴지통으로 이동된 항목이 이곳에 표시됩니다.", "trashed_items_will_be_permanently_deleted_after": "휴지통으로 이동된 항목은 {days, plural, one {#일} other {#일}} 후 영구적으로 삭제됩니다.", @@ -1214,13 +1309,15 @@ "unknown_album": "", "unknown_year": "알 수 없는 연도", "unlimited": "무제한", + "unlink_motion_video": "모션 비디오 링크 해제", "unlink_oauth": "OAuth 연결 해제", "unlinked_oauth_account": "OAuth 계정 연결이 해제되었습니다.", "unnamed_album": "이름 없는 앨범", + "unnamed_album_delete_confirmation": "선텍한 앨범을 삭제하시겠습니까?", "unnamed_share": "이름 없는 공유", "unsaved_change": "저장되지 않은 변경 사항", "unselect_all": "모두 선택 해제", - "unselect_all_duplicates": "모든 중복 선택 해제", + "unselect_all_duplicates": "모두 선택 해제", "unstack": "스택 해제", "unstacked_assets_count": "항목 {count, plural, one {#개} other {#개}}의 스택을 해제했습니다.", "untracked_files": "추적되지 않는 파일", @@ -1242,8 +1339,8 @@ "user": "사용자", "user_id": "사용자 ID", "user_liked": "{user}님이 {type, select, photo {이 사진을} video {이 동영상을} asset {이 항목을} other {이 항목을}} 좋아합니다.", - "user_purchase_settings": "결제", - "user_purchase_settings_description": "구매한 항목 관리", + "user_purchase_settings": "구매", + "user_purchase_settings_description": "구매 및 제품 키 관리", "user_role_set": "{user}님에게 {role} 역할을 설정했습니다.", "user_usage_detail": "사용자 사용량 상세", "username": "계정명", @@ -1254,6 +1351,8 @@ "version": "버전", "version_announcement_closing": "당신의 친구, Alex가", "version_announcement_message": "안녕하세요, 새 버전의 Immich를 사용할 수 있습니다. 자세한 내용은 릴리스 노트를 참조하세요. WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 docker-compose.yml.env 구성이 최신인지 확인하세요.", + "version_history": "버전 히스토리", + "version_history_item": "버전 {version}, {date} 설치됨", "video": "동영상", "video_hover_setting": "마우스 오버 재생", "video_hover_setting_description": "마우스를 동영상 위에 올리면 재생이 시작됩니다. 비활성화된 경우에도 재생 아이콘에 마우스를 올리면 재생이 시작됩니다.", @@ -1263,6 +1362,7 @@ "view_album": "앨범 보기", "view_all": "모두 보기", "view_all_users": "모든 사용자 보기", + "view_in_timeline": "타임라인에서 보기", "view_links": "링크 보기", "view_next_asset": "다음 항목 보기", "view_previous_asset": "이전 항목 보기", @@ -1273,7 +1373,7 @@ "warning": "경고", "week": "주", "welcome": "환영합니다", - "welcome_to_immich": "Immich에 오신 것을 환영합니다", + "welcome_to_immich": "환영합니다", "year": "년", "years_ago": "{years, plural, one {#년} other {#년}} 전", "yes": "네", diff --git a/web/src/lib/i18n/az.json b/i18n/lb.json similarity index 100% rename from web/src/lib/i18n/az.json rename to i18n/lb.json diff --git a/web/src/lib/i18n/lt.json b/i18n/lt.json similarity index 54% rename from web/src/lib/i18n/lt.json rename to i18n/lt.json index 9578806bdb..399ef31c3e 100644 --- a/web/src/lib/i18n/lt.json +++ b/i18n/lt.json @@ -7,6 +7,7 @@ "actions": "Veiksmai", "active": "Vykdoma", "activity": "Veikla", + "activity_changed": "Veikla yra {enabled, select, true {įjungta} other {išjungta}}", "add": "Pridėti", "add_a_description": "Pridėti aprašymą", "add_a_location": "Pridėti vietovę", @@ -15,7 +16,7 @@ "add_exclusion_pattern": "Pridėti išimčių šabloną", "add_import_path": "Pridėti importavimo kelią", "add_location": "Pridėti vietovę", - "add_more_users": "Pridėti daugiau vartotojų", + "add_more_users": "Pridėti daugiau naudotojų", "add_partner": "Pridėti partnerį", "add_path": "Pridėti kelią", "add_photos": "Pridėti nuotraukų", @@ -24,7 +25,7 @@ "add_to_shared_album": "Pridėti į bendrinamą albumą", "added_to_archive": "Pridėta į archyvą", "added_to_favorites": "Pridėta prie mėgstamiausių", - "added_to_favorites_count": "{count, number} pridėta prie mėgstamiausių", + "added_to_favorites_count": "{count, plural, one {# pridėtas} few {# pridėti} other {# pridėta}} prie mėgstamiausių", "admin": { "authentication_settings": "Autentifikavimo nustatymai", "authentication_settings_description": "Tvarkyti slaptažodžių, OAuth ir kitus autentifikavimo parametrus", @@ -34,43 +35,53 @@ "config_set_by_file": "Konfigūracija dabar nustatyta konfigūracinio failo", "confirm_delete_library": "Ar tikrai norite ištrinti {library} biblioteką?", "confirm_email_below": "Patvirtinimui įveskite \"{email}\" žemiau", + "confirm_reprocess_all_faces": "Ar tikrai norite iš naujo apdoroti visus veidus? Tai taip pat ištrins įvardytus asmenis.", "confirm_user_password_reset": "Ar tikrai norite iš naujo nustatyti {user} slaptažodį?", "crontab_guru": "", "disable_login": "Išjungti prisijungimą", "disabled": "", - "duplicate_detection_job_description": "", + "duplicate_detection_job_description": "Vykdykite mašininį mokymąsi tam, kad aptiktumėte panašius vaizdus. Nuo šios funkcijos priklauso išmanioji paieška", "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.", "external_library_created_at": "Išorinė biblioteka (sukurta {date})", "external_library_management": "Išorinių bibliotekų tvarkymas", - "face_detection": "Veido atpažinimas", - "image_format_description": "", - "image_prefer_embedded_preview": "", + "face_detection": "Veidų aptikimas", + "face_detection_description": "Veidų aptikimas bibliotekos elementuose naudojant mašininį mokymąsi. Vaizdo įrašų atveju naudojama tik miniatiūra. \"Atnaujinti\" iš naujo nuskaito visus bibliotekos elementus. \"Atstatyti\" ne tik atnaujina, bet ir išvalo visus esamus veidų duomenis. \"Trūkstami\" nuskaito tik dar nenuskaitytus bibliotekos elementus. Veidų aptikimo darbui pasibaigus, aptikti veidai patenka į veidų atpažinimo darbų eilę, kur jie priskiriami jau esamiems ar naujai atpažintiems žmonėms.", + "facial_recognition_job_description": "Aptiktų veidų atpažinimas ir priskyrimas žmonėms. Šis darbas vykdomas pasibaigus \"veidų aptikimo\" darbui. \"Atstatyti\" (per)grupuoja visus aptiktus veidus. \"Trūkstami\" apdoroja jokiam žmogui dar nepriskirtus aptiktus veidus.", + "failed_job_command": "Darbo {job} komanda {command} nepavyko", + "force_delete_user_warning": "ĮSPĖJIMAS: Šis veiksmas iš karto pašalins naudotoją ir visą jo informaciją. Šis žingsnis nesugrąžinamas ir failų nebus galima atkurti.", + "forcing_refresh_library_files": "Priverstinai atnaujinami visi failai bilbiotekoje", + "image_format_description": "WebP sukuria mažesnius failus nei JPEG, bet lėčiau juos apdoroja.", + "image_prefer_embedded_preview": "Pageidautinai rodyti įterptą peržiūrą", "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", + "image_prefer_wide_gamut": "Teikti pirmenybę plačiai gamai", "image_prefer_wide_gamut_setting_description": "", "image_preview_format": "Peržiūros formatas", "image_preview_resolution": "Peržiūros rezoliucija", - "image_preview_resolution_description": "", + "image_preview_resolution_description": "Naudojama peržiūrint vieną nuotrauką ir mašininiam mokymui. Didesnė rezoliucija gali išsaugoti daugiau detalių, bet ilgiau užtrukti apdoroti ir sumažinti programos greitumą.", "image_quality": "Kokybė", "image_quality_description": "Vaizdo kokybė nuo 1 iki 100. Aukštesnė kokybė yra geresnė, tačiau sukuriami didesni failai. Ši parinktis turi įtakos peržiūros ir miniatiūrų vaizdams.", - "image_settings": "", - "image_settings_description": "", + "image_settings": "Nuotraukos nustatymai", + "image_settings_description": "Keisti sugeneruotų nuotraukų kokybę ir rezoliuciją", "image_thumbnail_format": "Miniatūros formatas", "image_thumbnail_resolution": "Miniatūros rezoliucija", - "image_thumbnail_resolution_description": "", - "job_settings": "", - "job_settings_description": "", + "image_thumbnail_resolution_description": "Naudojama žiūrint nuotraukų grupes (pagrindinis nuotraukų puslapis, albumų peržiūra ir t.t.). Aukštesnė rezoliucija gali išlaikyti daugiau detalių, bet užtrunka ilgiau apdoroti, gali turėti didesnius failų dydžius ir gali sumažinti programos greitumą.", + "job_concurrency": "{job} lygiagretumas", + "job_not_concurrency_safe": "Šis darbas nėra saugus apdoroti lygiagrečiai.", + "job_settings": "Darbų nustatymai", + "job_settings_description": "Keisti darbų lygiagretumą", "job_status": "Darbų būsenos", "library_created": "Sukurta biblioteka: {library}", "library_cron_expression": "Cron išraiška", + "library_cron_expression_description": "Nustatykite nuskaitymo intervalą naudodami „cron“ formatą. Daugiau informacijos rasite pvz. Crontab Guru", "library_cron_expression_presets": "", "library_deleted": "Biblioteka ištrinta", + "library_import_path_description": "Nurodykite aplanką, kurį norite importuoti. Šiame aplanke, įskaitant poaplankius, bus nuskaityti vaizdai ir vaizdo įrašai.", "library_scanning": "Periodinis skanavimas", "library_scanning_description": "Konfigūruoti periodinį bibliotekos skanavimą", "library_scanning_enable_description": "Įgalinti periodinį bibliotekos skanavimą", "library_settings": "Išorinė biblioteka", "library_settings_description": "Tvarkyti išorinės bibliotekos parametrus", - "library_tasks_description": "", + "library_tasks_description": "Atlikit bibliotekos užduotis", "library_watching_enable_description": "", "library_watching_settings": "", "library_watching_settings_description": "", @@ -83,55 +94,63 @@ "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled": "Įgalinti mašininį mokymąsi", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "Veido atpažinimas", + "machine_learning_enabled_description": "Jei išjungta, visos „ML“ funkcijos bus išjungtos, nepaisant toliau pateiktų nustatymų.", + "machine_learning_facial_recognition": "Veidų atpažinimas", "machine_learning_facial_recognition_description": "Aptikti, atpažinti ir sugrupuoti veidus nuotraukose", - "machine_learning_facial_recognition_model": "Veido atpažinimo modelis", + "machine_learning_facial_recognition_model": "Veidų atpažinimo modelis", "machine_learning_facial_recognition_model_description": "", - "machine_learning_facial_recognition_setting": "Įgalinti veido atpažinimą", + "machine_learning_facial_recognition_setting": "Įgalinti veidų atpažinimą", "machine_learning_facial_recognition_setting_description": "", - "machine_learning_max_detection_distance": "", - "machine_learning_max_detection_distance_description": "", - "machine_learning_max_recognition_distance": "", + "machine_learning_max_detection_distance": "Maksimalus aptikimo atstumas", + "machine_learning_max_detection_distance_description": "Didžiausias atstumas tarp dviejų vaizdų, kad jie būtų laikomi dublikatais, svyruoja nuo 0,001 iki 0,1. Didesnės vertės aptiks daugiau dublikatų, tačiau gali būti klaidingai teigiami.", + "machine_learning_max_recognition_distance": "Maksimalus atpažinimo atstumas", "machine_learning_max_recognition_distance_description": "", "machine_learning_min_detection_score": "", "machine_learning_min_detection_score_description": "", - "machine_learning_min_recognized_faces": "", - "machine_learning_min_recognized_faces_description": "", + "machine_learning_min_recognized_faces": "Mažiausias atpažintų veidų skaičius", + "machine_learning_min_recognized_faces_description": "Mažiausias atpažintų veidų skaičius asmeniui, kurį reikia sukurti. Tai padidinus, veido atpažinimas tampa tikslesnis, bet padidėja tikimybė, kad veidas žmogui nepriskirtas.", "machine_learning_settings": "Mašininio mokymosi nustatymai", "machine_learning_settings_description": "Tvarkyti mašininio mokymosi funkcijas ir nustatymus", "machine_learning_smart_search": "Išmanioji paieška", "machine_learning_smart_search_description": "", "machine_learning_smart_search_enabled": "Įjungti išmaniąją paiešką", - "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", + "machine_learning_smart_search_enabled_description": "Jei išjungta, vaizdai nebus užkoduoti išmaniajai paieškai.", + "machine_learning_url_description": "Mašininio mokymosi serverio URL", + "manage_concurrency": "Tvarkyti lygiagretumą", "manage_log_settings": "", "map_dark_style": "Tamsioji tema", "map_enable_description": "", + "map_gps_settings": "Žemėlapio ir GPS nustatymai", + "map_gps_settings_description": "Tvarkyti žemėlapio ir GPS (atvirkštinio geokodavimo) nustatymus", "map_light_style": "Šviesioji tema", + "map_manage_reverse_geocoding_settings": "Tvarkyti atvirkštinio geokodavimo nustatymus", "map_reverse_geocoding": "Atvirkštinis geokodavimas", - "map_reverse_geocoding_enable_description": "", + "map_reverse_geocoding_enable_description": "Įjungti atvirkštinį geokodavimą", "map_reverse_geocoding_settings": "Atvirkštinio geokodavimo nustatymai", - "map_settings": "Žemėlapio nustatymai", + "map_settings": "Žemėlapis", "map_settings_description": "Tvarkyti žemėlapio parametrus", "map_style_description": "", - "metadata_extraction_job_description": "", + "metadata_extraction_job": "Metaduomenų nuskaitymas", + "metadata_extraction_job_description": "Kiekvieno bibliotekos elemento metaduomenų nuskaitymas, tokių kaip GPS koordinatės, veidai ar rezoliucija", "migration_job_description": "", + "no_paths_added": "Keliai nepridėti", + "no_pattern_added": "Šablonas nepridėtas", "notification_email_from_address": "", "notification_email_from_address_description": "", "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", + "notification_email_ignore_certificate_errors": "Nepaisyti sertifikatų klaidų", + "notification_email_ignore_certificate_errors_description": "Nepaisyti TLS sertifikato patvirtinimo klaidų (nerekomenduojama)", "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", - "notification_email_setting_description": "", - "notification_email_test_email_failed": "", - "notification_email_test_email_sent": "", + "notification_email_port_description": "El. pašto serverio prievadas (pvz. 25, 465 arba 587)", + "notification_email_sent_test_email_button": "Siųsti bandomąjį el. laišką ir išsaugoti", + "notification_email_setting_description": "El. pašto pranešimų siuntimo nustatymai", + "notification_email_test_email": "Išsiųsti bandomąjį el. laišką", + "notification_email_test_email_failed": "Nepavyko išsiųsti bandomojo el. laiško, patikrinkite savo nustatymus", + "notification_email_test_email_sent": "Bandomasis el. laiškas buvo išsiųstas į {email}. Patikrinkite savo pašto dėžutę.", "notification_email_username_description": "", "notification_enable_email_notifications": "", "notification_settings": "Pranešimų nustatymai", - "notification_settings_description": "Tvarkyti pranešimų parametrus, įskaitant el. pašto", + "notification_settings_description": "Tvarkyti pranešimų nustatymus, įskaitant el. pašto", "oauth_auto_launch": "Paleisti automatiškai", "oauth_auto_launch_description": "", "oauth_auto_register": "", @@ -146,7 +165,7 @@ "oauth_mobile_redirect_uri_override_description": "", "oauth_scope": "", "oauth_settings": "", - "oauth_settings_description": "", + "oauth_settings_description": "Tvarkyti OAuth prisijungimo nustatymus", "oauth_signing_algorithm": "", "oauth_storage_label_claim": "", "oauth_storage_label_claim_description": "", @@ -157,43 +176,53 @@ "offline_paths_description": "Šie rezultatai gali būti dėl rankinio failų ištrynimo, kurie nėra išorinės bibliotekos dalis.", "password_enable_description": "Prisijungti su el. paštu ir slaptažodžiu", "password_settings": "Prisijungimas slaptažodžiu", - "password_settings_description": "", + "password_settings_description": "Tvarkyti prisijungimo slaptažodžiu nustatymus", + "paths_validated_successfully": "Visi keliai patvirtinti sėkmingai", + "refreshing_all_libraries": "Perkraunamos visos bibliotekos", + "registration_description": "Kadangi esate pirmasis šio sistemos naudotojas, jums bus priskirta administratoriaus rolė, ir būsite atsakingas už administracines užduotis ir papildomų naudotojų kūrimą.", + "repair_all": "Pataisyti visus", + "require_password_change_on_login": "Reikalauti, kad naudotojas pasikeistų slaptažodį po pirmojo prisijungimo", + "reset_settings_to_default": "Atstatyti nustatymus į numatytuosius", "server_external_domain_settings": "Išorinis domenas", "server_external_domain_settings_description": "", "server_settings": "Serverio nustatymai", "server_settings_description": "Tvarkyti serverio nustatymus", "server_welcome_message": "", - "server_welcome_message_description": "", + "server_welcome_message_description": "Žinutė, rodoma prisijungimo puslapyje.", "sidecar_job_description": "", "slideshow_duration_description": "", - "smart_search_job_description": "", + "smart_search_job_description": "Vykdykite mašininį mokymąsi bibliotekos elementų išmaniajai paieškai", "storage_template_enable_description": "", "storage_template_hash_verification_enabled": "", "storage_template_hash_verification_enabled_description": "", "storage_template_migration_job": "", "storage_template_settings": "", "storage_template_settings_description": "", + "system_settings": "Sistemos nustatymai", + "tag_cleanup_job": "Žymų išvalymas", "theme_custom_css_settings": "", "theme_custom_css_settings_description": "", - "theme_settings": "", + "theme_settings": "Temos nustatymai", "theme_settings_description": "", - "thumbnail_generation_job_description": "", + "thumbnail_generation_job": "Generuoti miniatiūras", + "thumbnail_generation_job_description": "Didelių, mažų ir neryškių miniatiūrų generavimas kiekvienam bibliotekos elementui, taip pat miniatiūrų generavimas kiekvienam asmeniui", "transcode_policy_description": "", - "transcoding_acceleration_api": "", + "transcoding_acceleration_api": "Spartinimo API", "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", + "transcoding_acceleration_nvenc": "NVENC (reikalinga NVIDIA GPU)", "transcoding_acceleration_qsv": "", "transcoding_acceleration_rkmpp": "", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "", "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_containers": "Priimami konteineriai", "transcoding_accepted_video_codecs": "", "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", + "transcoding_advanced_options_description": "Parinktys, kurių daugelis naudotojų keisti neturėtų", "transcoding_audio_codec": "Garso kodekas", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", + "transcoding_audio_codec_description": "Opus yra aukščiausios kokybės variantas, tačiau turi mažesnį suderinamumą su senesniais įrenginiais ar programine įranga.", + "transcoding_bitrate_description": "Vaizdo įrašai viršija maksimalią leistiną bitų spartą arba nėra priimtino formato", + "transcoding_constant_quality_mode": "Pastovios kokybės režimas", "transcoding_constant_quality_mode_description": "", "transcoding_constant_rate_factor": "", "transcoding_constant_rate_factor_description": "", @@ -205,7 +234,7 @@ "transcoding_hevc_codec": "HEVC kodekas", "transcoding_max_b_frames": "", "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", + "transcoding_max_bitrate": "Maksimalus bitų srautas", "transcoding_max_bitrate_description": "", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", @@ -239,36 +268,43 @@ "trash_number_of_days_description": "", "trash_settings": "Šiukšliadėžės nustatymai", "trash_settings_description": "Tvarkyti šiukšliadėžės nustatymus", - "user_delete_delay_settings": "", + "untracked_files": "Nesekami failai", + "user_delete_delay_settings": "Ištrynimo delsa", "user_delete_delay_settings_description": "", - "user_password_has_been_reset": "Vartotojo slaptažodis buvo iš naujo nustatytas:", - "user_settings": "Vartotojo nustatymai", - "user_settings_description": "Valdyti vartotojo nustatymus", - "user_successfully_removed": "Vartotojas {email} sėkmingai pašalintas.", + "user_management": "Naudotojų valdymas", + "user_password_has_been_reset": "Naudotojo slaptažodis buvo iš naujo nustatytas:", + "user_restore_description": "Naudotojo {user} paskyra bus atkurta.", + "user_settings": "Naudotojo nustatymai", + "user_settings_description": "Valdyti naudotojo nustatymus", + "user_successfully_removed": "Naudotojas {email} sėkmingai pašalintas.", "version_check_enabled_description": "", "version_check_settings": "Versijos tikrinimas", "version_check_settings_description": "Įjungti/išjungti naujos versijos pranešimus", - "video_conversion_job_description": "" + "video_conversion_job": "Vaizdo įrašų konvertavimas", + "video_conversion_job_description": "Vaizdo įrašų konvertavimas platesniam suderinamumui su naršyklėmis ir įrenginiais" }, "admin_email": "Administratoriaus el. paštas", "admin_password": "Administratoriaus slaptažodis", "administration": "Administravimas", "advanced": "", "album_added": "Albumas pridėtas", - "album_added_notification_setting_description": "", + "album_added_notification_setting_description": "Gauti el. pašto pranešimą, kai būsite pridėtas prie bendrinamo albumo", "album_cover_updated": "Albumo viršelis atnaujintas", + "album_delete_confirmation": "Ar tikrai norite ištrinti albumą {album}?", "album_info_updated": "Albumo informacija atnaujinta", "album_leave": "Palikti albumą?", "album_leave_confirmation": "Ar tikrai norite palikti albumą {album}?", "album_name": "Albumo pavadinimas", "album_options": "Albumo parinktys", - "album_remove_user": "Pašalinti vartotoją?", - "album_remove_user_confirmation": "Ar tikrai norite pašalinti vartotoją {user}?", + "album_remove_user": "Pašalinti naudotoją?", + "album_remove_user_confirmation": "Ar tikrai norite pašalinti naudotoją {user}?", + "album_share_no_users": "Atrodo, kad bendrinate šį albumą su visais naudotojais, arba neturite naudotojų, su kuriais galėtumėte bendrinti.", "album_updated": "Albumas atnaujintas", - "album_updated_setting_description": "", + "album_updated_setting_description": "Gauti pranešimą el. paštu, kai bendrinamas albumas turi naujų elementų", "album_user_removed": "Pašalintas {user}", "album_with_link_access": "Tegul visi, turintys nuorodą, mato šio albumo nuotraukas ir žmones.", "albums": "Albumai", + "albums_count": "{count, plural, one {# albumas} few {# albumai} other {# albumų}}", "all": "Visi", "all_albums": "Visi albumai", "all_people": "Visi žmonės", @@ -278,34 +314,49 @@ "allow_public_user_to_download": "Leisti viešam naudotojui atsisiųsti", "allow_public_user_to_upload": "Leisti viešam naudotojui įkelti", "api_key": "API raktas", + "api_key_empty": "Jūsų API rakto pavadinimas netūrėtų būti tuščias", "api_keys": "API raktai", "app_settings": "Programos nustatymai", "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "archive": "Archyvas", + "archive_or_unarchive_photo": "Archyvuoti arba išarchyvuoti nuotrauką", "archive_size": "Archyvo dydis", + "archive_size_description": "Konfigūruoti archyvo dydį atsisiuntimams (GiB)", "archived": "", + "archived_count": "{count, plural, other {# suarchyvuota}}", "are_these_the_same_person": "Ar tai tas pats asmuo?", "are_you_sure_to_do_this": "Ar tikrai norite tai daryti?", "asset_added_to_album": "Pridėta į albumą", "asset_adding_to_album": "Pridedama į albumą...", + "asset_description_updated": "Elemento aprašymas buvo atnaujintas", "asset_offline": "", "asset_uploaded": "Įkelta", "asset_uploading": "Įkeliama...", - "assets": "", + "assets": "Elementai", + "assets_added_count": "{count, plural, one {Pridėtas # elementas} few {Pridėti # elementai} other {Pridėta # elementų}}", + "assets_added_to_album_count": "Į albumą {count, plural, one {įtrauktas # elementas} few {įtraukti # elementai} other {įtraukta # elementų}}", + "assets_added_to_name_count": "Į {hasName, select, true {{name}} other {naują}} albumą {count, plural, one {įtrauktas # elementas} few {įtraukti # elementai} other {įtraukta # elementų}}", + "assets_count": "{count, plural, one {# elementas} few {# elementai} other {# elementų}}", + "assets_moved_to_trash_count": "{count, plural, one {# elementas perkeltas} few {# elementai perkelti} other {# elementų perkelta}} į šiukšliadėžę", + "assets_permanently_deleted_count": "{count, plural, one {# elementas ištrintas} few {# elementai ištrinti} other {# elementų ištrinta}} visam laikui", + "assets_removed_count": "{count, plural, one {Pašalintas # elementas} few {Pašalinti # elementai} other {Pašalinta # elementų}}", + "assets_restored_count": "{count, plural, one {Atkurtas # elementas} few {Atkurti # elementai} other {Atkurta # elementų}}", + "assets_were_part_of_album_count": "{count, plural, one {# elementas} few {# elementai} other {# elementų}} jau prieš tai buvo albume", "authorized_devices": "Autorizuoti įrenginiai", "back": "Atgal", "back_close_deselect": "Atgal, uždaryti arba atžymėti", "backward": "", "birthdate_saved": "Sėkmingai išsaugota gimimo data", "blurred_background": "Neryškus fonas", + "bugs_and_feature_requests": "Klaidų ir funkcijų užklausos", + "buy": "Įsigyti Immich", "camera": "Fotoaparatas", "camera_brand": "Fotoaparato prekės ženklas", "camera_model": "Fotoaparato modelis", "cancel": "Atšaukti", "cancel_search": "Atšaukti paiešką", "cannot_merge_people": "Negalima sujungti asmenų", - "cannot_update_the_description": "", + "cannot_update_the_description": "Negalima atnaujinti aprašymo", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", @@ -316,8 +367,9 @@ "change_name": "Pakeisti vardą", "change_name_successfully": "", "change_password": "Pakeisti slaptažodį", + "change_password_description": "Tai arba pirmas kartas, kai jungiatės prie sistemos, arba buvo pateikta užklausa pakeisti jūsų slaptažodį. Prašome įvesti naują slaptažodį žemiau.", "change_your_password": "Pakeisti slaptažodį", - "changed_visibility_successfully": "", + "changed_visibility_successfully": "Matomumas pakeistas sėkmingai", "check_all": "Žymėti viską", "check_logs": "Tikrinti žurnalus", "city": "Miestas", @@ -338,14 +390,15 @@ "confirm_delete_shared_link": "Ar tikrai norite ištrinti šią bendrinamą nuorodą?", "confirm_password": "Patvirtinti slaptažodį", "contain": "", - "context": "", + "context": "Kontekstas", "continue": "Tęsti", - "copied_image_to_clipboard": "", + "copied_image_to_clipboard": "Nuotrauka nukopijuota į iškarpinę.", + "copied_to_clipboard": "Nukopijuota į iškapinę!", "copy_error": "Kopijavimo klaida", "copy_file_path": "Kopijuoti failo kelią", "copy_image": "Kopijuoti vaizdą", "copy_link": "Kopijuoti nuorodą", - "copy_link_to_clipboard": "", + "copy_link_to_clipboard": "Kopijuoti nuorodą į iškarpinę", "copy_password": "Kopijuoti slaptažodį", "copy_to_clipboard": "Kopijuoti į iškarpinę", "country": "Šalis", @@ -356,47 +409,57 @@ "create_library": "Sukurti biblioteką", "create_link": "Sukurti nuorodą", "create_link_to_share": "Sukurti bendrinimo nuorodą", - "create_new_person": "", + "create_link_to_share_description": "Leisti bet kam su nuoroda matyti pažymėtą(-as) nuotrauką(-as)", + "create_new_person": "Sukurti naują žmogų", + "create_new_person_hint": "Priskirti pasirinktus elementus naujam žmogui", "create_new_user": "Sukurti naują varotoją", - "create_user": "Sukurti vartotoją", + "create_tag": "Sukurti žymą", + "create_tag_description": "Sukurti naują žymą. Įdėtinėms žymoms įveskite pilną kelią, įskaitant pasviruosius brūkšnius.", + "create_user": "Sukurti naudotoją", "created": "Sukurta", "current_device": "Dabartinis įrenginys", "custom_locale": "", "custom_locale_description": "Formatuoti datas ir skaičius pagal kalbą ir regioną", "dark": "", - "date_after": "", + "date_after": "Data po", "date_and_time": "Data ir laikas", - "date_before": "", + "date_before": "Data prieš", + "date_of_birth_saved": "Gimimo data sėkmingai išsaugota", "date_range": "", "day": "Diena", "default_locale": "", - "default_locale_description": "", + "default_locale_description": "Formatuoti datas ir skaičius pagal jūsų naršyklės lokalę", "delete": "Ištrinti", "delete_album": "Ištrinti albumą", "delete_api_key_prompt": "Ar tikrai norite ištrinti šį API raktą?", + "delete_duplicates_confirmation": "Ar tikrai norite visam laikui ištrinti šiuos dublikatus?", "delete_key": "Ištrinti raktą", "delete_library": "Ištrinti biblioteką", "delete_link": "Ištrinti nuorodą", "delete_shared_link": "Ištrinti bendrinamą nuorodą", - "delete_user": "Ištrinti vartotoją", + "delete_tag": "Ištrinti žymą", + "delete_tag_confirmation_prompt": "Ar tikrai norite ištrinti žymą {tagName}?", + "delete_user": "Ištrinti naudotoją", "deleted_shared_link": "Bendrinama nuoroda ištrinta", "description": "Aprašymas", - "details": "", + "details": "Detalės", "direction": "Kryptis", "disabled": "Išjungta", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", + "disallow_edits": "Neleisti redaguoti", + "discover": "Atrasti", + "dismiss_all_errors": "Nepaisyti visų klaidų", + "dismiss_error": "Nepaisyti klaidos", "display_options": "", "display_order": "Atvaizdavimo tvarka", "display_original_photos": "Rodyti originalias nuotraukas", "display_original_photos_setting_description": "", "do_not_show_again": "Daugiau nerodyti šio pranešimo", + "documentation": "Dokumentacija", "done": "", "download": "Atsisiųsti", "download_settings": "Atsisiųsti", "downloading": "Siunčiama", + "duplicates": "Dublikatai", "duration": "Trukmė", "durations": { "days": "", @@ -410,18 +473,19 @@ "edit_avatar": "Redaguoti avatarą", "edit_date": "Redaguoti datą", "edit_date_and_time": "Redaguoti datą ir laiką", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", + "edit_exclusion_pattern": "Redaguoti išimčių šabloną", + "edit_faces": "Redaguoti veidus", + "edit_import_path": "Redaguoti importavimo kelią", + "edit_import_paths": "Redaguoti importavimo kelius", + "edit_key": "Redaguoti raktą", "edit_link": "Redaguoti nuorodą", "edit_location": "Redaguoti vietovę", "edit_name": "Redaguoti vardą", - "edit_people": "", + "edit_people": "Redaguoti žmones", + "edit_tag": "Redaguoti žymą", "edit_title": "Redaguoti antraštę", - "edit_user": "Redaguoti vartotoją", - "edited": "", + "edit_user": "Redaguoti naudotoją", + "edited": "Redaguota", "editor": "", "email": "El. paštas", "empty": "", @@ -431,18 +495,33 @@ "enabled": "Įgalintas", "end_date": "Pabaigos data", "error": "Klaida", - "error_loading_image": "", + "error_loading_image": "Klaida įkeliant vaizdą", "error_title": "Klaida - Kažkas nutiko ne taip", "errors": { "cant_apply_changes": "Negalima taikyti pakeitimų", + "error_adding_assets_to_album": "Klaida pridedant elementus į albumą", + "error_adding_users_to_album": "Klaida pridedant naudotojus prie albumo", + "error_downloading": "Klaida atsisiunčiant {filename}", + "error_hiding_buy_button": "Klaida slepiant pirkimo mygtuką", + "error_removing_assets_from_album": "Klaida šalinant elementus iš albumo, patikrinkite konsolę dėl išsamesnės informacijos", + "exclusion_pattern_already_exists": "Šis išimčių šablonas jau egzistuoja.", "failed_to_create_album": "Nepavyko sukurti albumo", "failed_to_create_shared_link": "Nepavyko sukurti bendrinamos nuorodos", "failed_to_edit_shared_link": "Nepavyko redaguoti bendrinamos nuorodos", + "failed_to_load_people": "Nepavyko užkrauti žmonių", + "failed_to_remove_product_key": "Nepavyko pašalinti produkto rakto", + "failed_to_stack_assets": "Nepavyko sugrupuoti elementų", + "failed_to_unstack_assets": "Nepavyko išgrupuoti elementų", + "import_path_already_exists": "Šis importavimo kelias jau egzistuoja.", "incorrect_email_or_password": "Neteisingas el. pašto adresas arba slaptažodis", - "unable_to_add_album_users": "", + "profile_picture_transparent_pixels": "Profilio nuotrauka negali turėti permatomų pikselių. Prašome priartinti ir/arba perkelkite nuotrauką.", + "quota_higher_than_disk_size": "Nustatyta kvota, viršija disko dydį", + "unable_to_add_album_users": "Nepavyksta pridėti naudotojų prie albumo", "unable_to_add_comment": "Nepavyksta pridėti komentaro", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", + "unable_to_add_exclusion_pattern": "Nepavyksta pridėti išimčių šablono", + "unable_to_add_import_path": "Nepavyksta pridėti importavimo kelio", + "unable_to_add_partners": "Nepavyksta pridėti partnerių", + "unable_to_change_album_user_role": "Nepavyksta pakeisti albumo naudotojo rolės", "unable_to_change_date": "Negalima pakeisti datos", "unable_to_change_location": "Negalima pakeisti vietos", "unable_to_change_password": "Negalima pakeisti slaptažodžio", @@ -450,28 +529,39 @@ "unable_to_check_items": "", "unable_to_connect": "Nepavyko prisijungti", "unable_to_connect_to_server": "Nepavyko prisijungti prie serverio", + "unable_to_copy_to_clipboard": "Negalima kopijuoti į iškarpinę, įsitikinkite, kad prie puslapio prieinate per https", "unable_to_create_admin_account": "Nepavyko sukurti administratoriaus paskyros", "unable_to_create_api_key": "Nepavyko sukurti naujo API rakto", "unable_to_create_library": "Nepavyko sukurti bibliotekos", - "unable_to_create_user": "Nepavyko sukurti vartotojo", - "unable_to_delete_album": "", + "unable_to_create_user": "Nepavyko sukurti naudotojo", + "unable_to_delete_album": "Nepavyksta ištrinti albumo", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_exclusion_pattern": "Nepavyksta ištrinti išimčių šablono", + "unable_to_delete_import_path": "Nepavyksta ištrinti importavimo kelio", + "unable_to_delete_shared_link": "Nepavyksta ištrinti bendrinimo nuorodos", + "unable_to_delete_user": "Nepavyksta ištrinti naudotojo", + "unable_to_edit_exclusion_pattern": "Nepavyksta redaguoti išimčių šablono", + "unable_to_edit_import_path": "Nepavyksta redaguoti išimčių kelio", "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", + "unable_to_enter_fullscreen": "Nepavyksta pereiti į viso ekrano režimą", + "unable_to_exit_fullscreen": "Nepavyksta išeiti iš viso ekrano režimo", + "unable_to_get_shared_link": "Nepavyksta gauti bendrinamos nuorodos", + "unable_to_hide_person": "Nepavyksta paslėpti žmogaus", + "unable_to_load_album": "Nepavyksta užkrauti albumo", "unable_to_load_asset_activity": "", "unable_to_load_items": "", "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", + "unable_to_log_out_all_devices": "Nepavyksta atjungti visų įrenginių", + "unable_to_log_out_device": "Nepavyksta atjungti įrenginio", + "unable_to_login_with_oauth": "Nepavyksta prisijungti su OAuth", + "unable_to_play_video": "Nepavyksta paleisti vaizdo įrašo", + "unable_to_refresh_user": "Nepavyksta atnaujinti naudotojo", "unable_to_remove_album_users": "", + "unable_to_remove_api_key": "Nepavyko pašalinti API rakto", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Nepavyksta pašalinti bibliotekos", + "unable_to_remove_partner": "Nepavyksta pašalinti partnerio", + "unable_to_remove_reaction": "Nepavyksta pašalinti reakcijos", "unable_to_remove_user": "", "unable_to_repair_items": "", "unable_to_reset_password": "", @@ -500,40 +590,47 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", + "exif": "Exif", "exit_slideshow": "", "expand_all": "Išskleisti viską", "expire_after": "", - "expired": "", + "expired": "Nebegalioja", + "expires_date": "Nebegalios už {date}", "explore": "Naršyti", "export": "Eksportuoti", "export_as_json": "Eksportuoti kaip JSON", "extension": "Plėtinys", + "external": "Išorinis", "external_libraries": "Išorinės bibliotekos", "face_unassigned": "Nepriskirta", "failed_to_get_people": "", - "favorite": "Mėgstamiausias", - "favorite_or_unfavorite_photo": "", + "favorite": "Mėgstamiausi", + "favorite_or_unfavorite_photo": "Įtraukti prie arba pašalinti iš mėgstamiausių", "favorites": "Mėgstamiausi", "feature": "", "feature_photo_updated": "", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "file_name": "Failo pavadinimas", + "file_name_or_extension": "Failo pavadinimas arba plėtinys", "filename": "", "files": "", - "filetype": "", - "filter_people": "", + "filetype": "Failo tipas", + "filter_people": "Filtruoti žmones", "fix_incorrect_match": "", + "folders": "Aplankai", "force_re-scan_library_files": "", "forward": "", "general": "", - "get_help": "", + "get_help": "Gauti pagalbos", "getting_started": "", "go_back": "", "go_to_search": "", "go_to_share_page": "", - "group_albums_by": "", - "has_quota": "", + "group_albums_by": "Grupuoti albumus pagal...", + "group_no": "Negrupuoti", + "group_owner": "Grupuoti pagal savininką", + "group_year": "Grupuoti pagal metus", + "has_quota": "Turi kvotą", "hi_user": "Labas {name} ({email})", "hide_all_people": "Slėpti visus asmenis", "hide_gallery": "Slėpti galeriją", @@ -545,7 +642,7 @@ "hour": "Valanda", "image": "Nuotrauka", "img": "", - "immich_logo": "", + "immich_logo": "Immich logotipas", "import_from_json": "Importuoti iš JSON", "import_path": "Importavimo kelias", "in_archive": "Archyve", @@ -562,6 +659,7 @@ }, "invite_people": "Kviesti žmones", "invite_to_album": "Pakviesti į albumą", + "items_count": "{count, plural, one {# elementas} few {# elementai} other {# elementų}}", "job_settings_description": "", "jobs": "Darbai", "keep": "Palikti", @@ -574,7 +672,7 @@ "latitude": "Platuma", "leave": "Išeiti", "let_others_respond": "Leisti kitiems reaguoti", - "level": "", + "level": "Lygis", "library": "Biblioteka", "library_options": "Bibliotekos pasirinktys", "light": "", @@ -583,7 +681,7 @@ "linked_oauth_account": "", "list": "Sąrašas", "loading": "Kraunama", - "loading_search_results_failed": "", + "loading_search_results_failed": "Nepavyko užkrauti paieškos rezultatų", "log_out": "Atsijungti", "log_out_all_devices": "Atsijungti iš visų įrenginių", "logged_out_all_devices": "Atsijungta iš visų įrenginių", @@ -593,9 +691,9 @@ "logout_this_device_confirmation": "Ar tikrai norite atsijungti iš šio prietaiso?", "longitude": "Ilguma", "look": "", - "loop_videos": "", + "loop_videos": "Kartoti vaizdo įrašus", "loop_videos_description": "", - "make": "", + "make": "Gamintojas", "manage_shared_links": "Bendrai naudojamų nuorodų tvarkymas", "manage_sharing_with_partners": "Valdyti dalijimąsi su partneriais", "manage_the_app_settings": "Valdyti programos nustatymus", @@ -617,6 +715,7 @@ "merge_people_limit": "Vienu metu galite sujungti tik iki 5 veidų", "merge_people_prompt": "Ar norite sujungti šiuos asmenis? Šis veiksmas yra negrįžtamas.", "merge_people_successfully": "Asmenys sėkmingai sujungti", + "merged_people_count": "{count, plural, one {Sujungtas # asmuo} few {Sujungti # asmenys} other {Sujungta # asmenų}}", "minimize": "Sumažinti", "minute": "Minutė", "missing": "Trūkstami", @@ -628,12 +727,13 @@ "name": "Vardas", "name_or_nickname": "Vardas arba slapyvardis", "never": "Niekada", + "new_album": "Naujas albumas", "new_api_key": "Naujas API raktas", "new_password": "Naujas slaptažodis", "new_person": "Naujas asmuo", - "new_user_created": "Sukurtas naujas vartotojas", + "new_user_created": "Naujas naudotojas sukurtas", "new_version_available": "PRIEINAMA NAUJA VERSIJA", - "newest_first": "", + "newest_first": "Pirmiausia naujausi", "next": "Sekantis", "next_memory": "Sekantis atsiminimas", "no": "Ne", @@ -653,22 +753,25 @@ "no_results_description": "Pabandykite sinonimą arba bendresnį raktažodį", "no_shared_albums_message": "", "not_in_any_album": "Nė viename albume", + "note_unlimited_quota": "Pastaba: Įveskite 0, jei norite neribotos kvotos", "notes": "Pastabos", "notification_toggle_setting_description": "Įjungti el. pašto pranešimus", "notifications": "Pranešimai", "notifications_setting_description": "Tvarkyti pranešimus", "oauth": "", + "official_immich_resources": "Oficialūs Immich ištekliai", "offline": "Neprisijungęs", "ok": "Ok", "oldest_first": "Seniausias pirmas", "onboarding_welcome_user": "Sveiki atvykę, {user}", "online": "Prisijungęs", "only_favorites": "Tik mėgstamiausi", - "only_refreshes_modified_files": "", - "open_the_search_filters": "", + "only_refreshes_modified_files": "Atnaujina tik modifikuotus failus", + "open_the_search_filters": "Atidaryti paieškos filtrus", "options": "Pasirinktys", "or": "arba", "organize_your_library": "Tvarkykite savo biblioteką", + "original": "Originalas", "other": "", "other_devices": "Kiti įrenginiai", "other_variables": "Kiti kintamieji", @@ -691,24 +794,27 @@ }, "path": "Kelias", "pattern": "", - "pause": "", + "pause": "Sustabdyti", "pause_memories": "", - "paused": "", + "paused": "Sustabdyta", "pending": "Laukiama", "people": "Asmenys", + "people_edits_count": "{count, plural, one {Redaguotas # asmuo} few {Redaguoti # asmenys} other {Redaguota # asmenų}}", "people_sidebar_description": "", "perform_library_tasks": "", "permanent_deletion_warning": "", "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", + "permanently_delete": "Ištrinti visam laikui", + "permanently_delete_assets_count": "Visam laikui ištrinti {count, plural, one {# elementą} few {# elementus} other {# elementų}}", "permanently_deleted_asset": "", + "permanently_deleted_assets_count": "Visam laikui {count, plural, one {ištrintas # elementas} few {ištrinti # elementai} other {ištrinta # elementų}}", "photos": "Nuotraukos", "photos_and_videos": "Nuotraukos ir vaizdo įrašai", "photos_count": "{count, plural, one {{count, number} nuotrauka} few {{count, number} nuotraukos} other {{count, number} nuotraukų}}", "photos_from_previous_years": "Ankstesnių metų nuotraukos", "pick_a_location": "", - "place": "", - "places": "", + "place": "Vieta", + "places": "Vietos", "play": "", "play_memories": "", "play_motion_photo": "", @@ -721,44 +827,88 @@ "previous_memory": "", "previous_or_next_photo": "", "primary": "", - "profile_picture_set": "", + "profile_picture_set": "Profilio nuotrauka nustatyta.", + "public_album": "Viešas albumas", "public_share": "", + "purchase_account_info": "Rėmėjas", + "purchase_activated_subtitle": "Dėkojame, kad remiate Immich ir atviro kodo programinę įrangą", + "purchase_activated_time": "Suaktyvinta {date, date}", + "purchase_activated_title": "Jūsų raktas sėkmingai aktyvuotas", + "purchase_button_activate": "Aktyvuoti", + "purchase_button_buy": "Pirkti", + "purchase_button_buy_immich": "Pirkti Immich", + "purchase_button_never_show_again": "Niekada daugiau nerodyti", + "purchase_button_reminder": "Priminti man po 30 dienų", + "purchase_button_remove_key": "Pašalinti produkto rakta", + "purchase_button_select": "Pasirinkti", + "purchase_failed_activation": "Nepavyko suaktyvinti! Patikrinkite el. paštą, ar turite teisingo produkto koda!", + "purchase_individual_description_1": "Asmeniui", + "purchase_individual_description_2": "Rėmėjo statusas", + "purchase_input_suggestion": "Turite produkto raktą? Įveskite jį žemiau", + "purchase_license_subtitle": "Įsigykite „Immich“, kad palaikytumėte tolesnį paslaugos vystymą", + "purchase_lifetime_description": "Pirkimas visam gyvenimui", + "purchase_option_title": "PIRKIMO PASIRINKIMAS", + "purchase_panel_info_1": "„Immich“ kūrimas užima daug laiko ir pastangų, o visą darbo dieną dirba inžinieriai, kad jis būtų kuo geresnis. Mūsų misija yra, kad atvirojo kodo programinė įranga ir etiška verslo praktika taptų tvariu kūrėjų pajamų šaltiniu ir sukurtų privatumą gerbiančią ekosistemą su realiomis alternatyvomis išnaudojamoms debesijos paslaugoms.", + "purchase_panel_info_2": "Kadangi esame įsipareigoję nepridėti mokamų sienų, šis pirkinys nesuteiks jums jokių papildomų „Immich“ funkcijų. Mes tikime, kad tokie naudotojai kaip jūs palaikys nuolatinį „Immich“ vystymąsi.", + "purchase_panel_title": "Palaikykite projektą", + "purchase_per_server": "Vienam serveriui", + "purchase_per_user": "Vienam naudotojui", + "purchase_remove_product_key": "Pašalinti produkto raktą", + "purchase_remove_product_key_prompt": "Ar tikrai norite pašalinti produkto raktą?", + "purchase_remove_server_product_key": "Pašalinti serverio produkto raktą", + "purchase_remove_server_product_key_prompt": "Ar tikrai norite pašalinti serverio produkto raktą?", + "purchase_server_description_1": "Visam serveriui", + "purchase_server_description_2": "Rėmėjo statusas", + "purchase_server_title": "Serveris", + "purchase_settings_server_activated": "Serverio produkto raktas yra tvarkomas administratoriaus", "range": "", + "rating": "Įvertinimas žvaigždutėmis", + "rating_count": "{count, plural, one {# įvertinimas} few {# įvertinimai} other {# įvertinimų}}", "raw": "", "reaction_options": "", "read_changelog": "", "recent": "", "recent_searches": "", - "refresh": "", - "refreshed": "", + "refresh": "Atnaujinti", + "refreshed": "Atnaujinta", "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", + "remove": "Pašalinti", + "remove_deleted_assets": "", + "remove_from_album": "Pašalinti iš albumo", + "remove_from_favorites": "Pašalinti iš mėgstamiausių", "remove_from_shared_link": "", - "remove_offline_files": "", - "repair": "", + "remove_user": "Pašalinti naudotoją", + "removed_api_key": "Pašalintas API Raktas: {name}", + "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_tagged_assets": "Žyma pašalinta iš {count, plural, one {# elemento} other {# elementų}}", + "rename": "Pervadinti", + "repair": "Pataisyti", "repair_no_results_message": "", "replace_with_upload": "", - "require_password": "", - "reset": "", + "require_password": "Reikalauti slaptažodžio", + "reset": "Atstatyti", "reset_password": "", "reset_people_visibility": "", "reset_settings_to_default": "", + "resolved_all_duplicates": "Išspręsti visi dublikatai", "restore": "Atkurti", "restore_all": "Atkurti visus", - "restore_user": "Atkurti vartotoją", + "restore_user": "Atkurti naudotoją", "retry_upload": "", "review_duplicates": "", "role": "", "save": "Išsaugoti", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", + "saved_api_key": "Išsaugotas API raktas", + "saved_profile": "Išsaugotas profilis", + "saved_settings": "Išsaugoti nustatymai", + "say_something": "Ką nors pasakykite", + "scan_all_libraries": "Skenuoti visas bibliotekas", "scan_all_library_files": "", + "scan_library": "Skenuoti", "scan_new_library_files": "", - "scan_settings": "", + "scan_settings": "Skenavimo nustatymai", "search": "Ieškoti", "search_albums": "", "search_by_context": "Ieškoti pagal kontekstą", @@ -767,11 +917,14 @@ "search_camera_make": "", "search_camera_model": "", "search_city": "", - "search_country": "", + "search_country": "Ieškoti šalies...", "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "search_no_people_named": "Nėra žmonių vardu „{name}“", + "search_people": "Ieškoti žmonių", + "search_places": "Ieškoti vietų", + "search_settings": "Ieškoti nustatymų", "search_state": "", + "search_tags": "Ieškoti žymų...", "search_timezone": "", "search_type": "Paieškos tipas", "search_your_photos": "Ieškoti nuotraukų", @@ -779,16 +932,20 @@ "second": "", "select_album_cover": "", "select_all": "", + "select_all_duplicates": "Pasirinkti visus dublikatus", "select_avatar_color": "Pasirinkti avataro spalvą", "select_face": "Pasirinkti veidą", - "select_featured_photo": "", - "select_library_owner": "", + "select_featured_photo": "Pasirinkti rodomą nuotrauką", + "select_library_owner": "Pasirinkti bibliotekos savininką", "select_new_face": "", "select_photos": "", "selected": "Pasirinkta", + "selected_count": "{count, plural, one {# pasirinktas} few {# pasirinkti} other {# pasirinktų}}", "send_message": "Siųsti žinutę", "send_welcome_email": "Siųsti sveikinimo el. laišką", "server": "Serveris", + "server_offline": "Serveris nepasiekiamas", + "server_online": "Serveris pasiekiamas", "server_stats": "Serverio statistika", "server_version": "Serverio versija", "set": "Nustatyti", @@ -804,6 +961,7 @@ "shared_by": "", "shared_by_you": "", "shared_links": "", + "shared_photos_and_videos_count": "{assetCount, plural, one {# bendrinama nuotrauka ir vaizdo įrašas} few {# bendrinamos nuotraukos ir vaizdo įrašai} other {# bendrinamų nuotraukų ir vaizdo įrašų}}", "shared_with_partner": "Pasidalinta su {partner}", "sharing": "Dalijimasis", "sharing_enter_password": "Norėdami peržiūrėti šį puslapį, įveskite slaptažodį.", @@ -821,6 +979,8 @@ "show_person_options": "", "show_progress_bar": "", "show_search_options": "Rodyti paieškos parinktis", + "show_supporter_badge": "Rėmėjo ženklelis", + "show_supporter_badge_description": "Rodyti rėmėjo ženklelį", "shuffle": "", "sign_out": "Atsijungti", "sign_up": "Užsiregistruoti", @@ -835,9 +995,13 @@ "sort_recent": "Naujausia nuotrauka", "sort_title": "Pavadinimas", "source": "Šaltinis", - "stack": "", - "stack_selected_photos": "", + "stack": "Grupuoti", + "stack_duplicates": "Grupuoti dublikatus", + "stack_select_one_photo": "Pasirinkti pagrindinę grupės nuotrauką", + "stack_selected_photos": "Grupuoti pasirinktas nuotraukas", + "stacked_assets_count": "{count, plural, one {Sugrupuotas # elementas} few {Sugrupuoti # elementai} other {Sugrupuota # elementų}}", "stacktrace": "", + "start": "Pradėti", "start_date": "Pradžios data", "state": "", "status": "Statusas", @@ -848,17 +1012,24 @@ "submit": "Pateikti", "suggestions": "", "sunrise_on_the_beach": "Saulėtekis paplūdimyje", + "support_and_feedback": "Palaikymas ir atsiliepimai", "swap_merge_direction": "", "sync": "Sinchronizuoti", + "tag": "Žyma", + "tag_created": "Sukurta žyma: {tag}", + "tag_not_found_question": "Nerandate žymos? Sukurti naują žymą.", + "tag_updated": "Atnaujinta žyma: {tag}", + "tagged_assets": "Žyma pridėta prie {count, plural, one {# elemento} other {# elementų}}", + "tags": "Žymos", "template": "Šablonas", "theme": "Tema", "theme_selection": "", "theme_selection_description": "", "time_based_memories": "", "timezone": "Laiko juosta", - "to_archive": "Archyvas", + "to_archive": "Archyvuoti", "to_change_password": "Pakeisti slaptažodį", - "to_favorite": "Mėgstamiausi", + "to_favorite": "Įtraukti prie mėgstamiausių", "toggle_settings": "", "toggle_theme": "", "toggle_visibility": "", @@ -867,10 +1038,12 @@ "trash_all": "Ištrinti visus", "trash_count": "Šiukšliadėžė {count, number}", "trash_no_results_message": "", - "type": "", + "trashed_items_will_be_permanently_deleted_after": "Į šiukšliadėžę perkelti elementai bus visam laikui ištrinti po {days, plural, one {# dienos} other {# dienų}}.", + "type": "Tipas", "unarchive": "Išarchyvuoti", "unarchived": "", - "unfavorite": "", + "unarchived_count": "{count, plural, other {# išarchyvuota}}", + "unfavorite": "Pašalinti iš mėgstamiausių", "unhide_person": "", "unknown": "", "unknown_album": "", @@ -879,7 +1052,8 @@ "unlinked_oauth_account": "", "unsaved_change": "Neišsaugoti pakeitimai", "unselect_all": "", - "unstack": "", + "unstack": "Išgrupuoti", + "unstacked_assets_count": "{count, plural, one {Išgrupuotas # elementas} few {Išgrupuoti # elementai} other {Išgrupuota # elementų}}", "up_next": "", "updated_password": "Slaptažodis atnaujintas", "upload": "Įkelti", @@ -890,26 +1064,30 @@ "upload_status_uploaded": "Įkelta", "url": "", "usage": "", - "user": "Vartotojas", - "user_id": "Vartotojo ID", + "user": "Naudotojas", + "user_id": "Naudotojo ID", "user_usage_detail": "", - "username": "Vartotojo vardas", - "users": "Vartotojai", - "utilities": "", + "username": "Naudotojo vardas", + "users": "Naudotojai", + "utilities": "Priemonės", "validate": "", "variables": "Kintamieji", "version": "Versija", "version_announcement_closing": "Tavo draugas, Alex", - "video": "", + "version_history": "Versijų istorija", + "version_history_item": "Versija {version} įdiegta {date}", + "video": "Vaizdo įrašas", "video_hover_setting_description": "", "videos": "Video", + "videos_count": "{count, plural, one {# vaizdo įrašas} few {# vaizdo įrašai} other {# vaizdo įrašų}}", "view": "Rodyti", "view_album": "Rodyti albumą", "view_all": "", - "view_all_users": "Rodyti visus vartotojus", + "view_all_users": "Peržiūrėti visus naudotojus", "view_links": "Rodyti nuorodas", "view_next_asset": "", "view_previous_asset": "", + "view_stack": "Peržiūrėti grupę", "viewer": "", "waiting": "Laukiama", "warning": "Įspėjimas", @@ -917,5 +1095,5 @@ "welcome_to_immich": "", "year": "Metai", "yes": "Taip", - "zoom_image": "" + "zoom_image": "Priartinti vaizdą" } diff --git a/web/src/lib/i18n/lv.json b/i18n/lv.json similarity index 60% rename from web/src/lib/i18n/lv.json rename to i18n/lv.json index bf17ccb813..b2c57d7595 100644 --- a/web/src/lib/i18n/lv.json +++ b/i18n/lv.json @@ -2,7 +2,7 @@ "about": "Par", "account": "Konts", "account_settings": "Konta iestatījumi", - "acknowledge": "Atzīt", + "acknowledge": "Pieņemt", "action": "Darbība", "actions": "Darbības", "active": "Aktīvs", @@ -25,7 +25,7 @@ "add_to_shared_album": "Pievienot koplietotam albumam", "added_to_archive": "Pievienots arhīvam", "added_to_favorites": "Pievienots izlasei", - "added_to_favorites_count": "Pievienots {count} izlasei", + "added_to_favorites_count": "Pievienots {count, number} izlasei", "admin": { "add_exclusion_pattern_description": "Pievienojiet izlaišanas shēmas. Aizstājējzīmju izmantoša *, **, un ? tiek atbalstīta. Lai ignorētu visus failus jebkurā direktorijā ar nosaukumu “RAW”, izmantojiet “**/RAW/**”. Lai ignorētu visus failus, kas beidzas ar “. tif”, izmantojiet “**/*. tif”. Lai ignorētu absolūto ceļu, izmantojiet “/path/to/ignore/**”.", "authentication_settings": "Autentifikācijas iestatījumi", @@ -40,10 +40,15 @@ "confirm_email_below": "Lai apstiprinātu, zemāk ierakstiet “{email}”", "confirm_reprocess_all_faces": "Vai tiešām vēlaties atkārtoti apstrādāt visas sejas? Tas arī atiestatīs cilvēkus ar vārdiem.", "confirm_user_password_reset": "Vai tiešām vēlaties atiestatīt lietotāja {user} paroli?", + "create_job": "Izveidot darbu", "crontab_guru": "", "disable_login": "Atspējot pieteikšanos", "disabled": "", "duplicate_detection_job_description": "Palaidiet mašīnmācīšanos uz līdzekļiem, lai noteiktu līdzīgus attēlus. Paļaujas uz Viedo Meklēšanu", + "external_library_created_at": "Ārēja bibliotēka (izveidota {date})", + "external_library_management": "Ārējo bibliotēku pārvaldība", + "face_detection": "Seju noteikšana", + "image_format": "Formāts", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -54,15 +59,20 @@ "image_preview_resolution_description": "", "image_quality": "Kvalitāte", "image_quality_description": "Attēla kvalitāte no 1 līdz 100. Augstāka kvalitāte ir labāka, bet veido lielākus failus. Šī opcija ietekmē Priekšskatījums un Sīktēls attēlus.", + "image_resolution": "Izšķirtspēja", "image_settings": "Attēla Iestatījumi", "image_settings_description": "Ģenerēto attēlu kvalitātes un izšķirtspējas pārvaldība", "image_thumbnail_format": "Sīktēlu formāts", "image_thumbnail_resolution": "Sīktēlu izšķirtspēja", "image_thumbnail_resolution_description": "", + "image_thumbnail_title": "Sīktēlu iestatījumi", + "job_created": "Darbs izveidots", "job_settings": "", "job_settings_description": "", - "library_cron_expression": "", + "job_status": "Darbu statuss", + "library_cron_expression": "Cron izteiksme", "library_cron_expression_presets": "", + "library_deleted": "Bibliotēka dzēsta", "library_scanning": "", "library_scanning_description": "", "library_scanning_enable_description": "", @@ -75,14 +85,14 @@ "logging_enable_description": "", "logging_level_description": "", "logging_settings": "", - "machine_learning_clip_model": "", - "machine_learning_duplicate_detection": "", + "machine_learning_clip_model": "CLIP modelis", + "machine_learning_duplicate_detection": "Dublikātu noteikšana", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", + "machine_learning_facial_recognition": "Seju atpazīšana", "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", + "machine_learning_facial_recognition_model": "Seju atpazīšanas modelis", "machine_learning_facial_recognition_model_description": "", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", @@ -93,55 +103,62 @@ "machine_learning_min_detection_score_description": "", "machine_learning_min_recognized_faces": "", "machine_learning_min_recognized_faces_description": "", - "machine_learning_settings": "", + "machine_learning_settings": "Mašīnmācīšanās iestatījumi", "machine_learning_settings_description": "", - "machine_learning_smart_search": "", + "machine_learning_smart_search": "Viedā meklēšana", "machine_learning_smart_search_description": "", "machine_learning_smart_search_enabled_description": "", - "machine_learning_url_description": "", + "machine_learning_url_description": "Mašīnmācīšanās servera URL", "manage_log_settings": "", "map_dark_style": "", "map_enable_description": "", + "map_gps_settings": "Kartes un GPS iestatījumi", + "map_gps_settings_description": "Pārvaldīt karšu un GPS (apgrieztās ģeokodēšanas) iestatījumus", "map_light_style": "", "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Karte", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", + "metadata_settings": "Metadatu iestatījumi", + "migration_job": "Migrācija", "migration_job_description": "", - "notification_email_from_address": "", + "notification_email_from_address": "No adreses", "notification_email_from_address_description": "", "notification_email_host_description": "", - "notification_email_ignore_certificate_errors": "", - "notification_email_ignore_certificate_errors_description": "", + "notification_email_ignore_certificate_errors": "Ignorēt sertifikātu kļūdas", + "notification_email_ignore_certificate_errors_description": "Ignorēt TLS sertifikāta apstiprināšanas kļūdas (nav ieteicams)", "notification_email_password_description": "", - "notification_email_port_description": "", - "notification_email_sent_test_email_button": "", + "notification_email_port_description": "e-pasta servera ports (piemēram, 25, 465 vai 587)", + "notification_email_sent_test_email_button": "Nosūtīt testa e-pastu un saglabāt", "notification_email_setting_description": "", + "notification_email_test_email": "Nosūtīt testa e-pastu", "notification_email_test_email_failed": "", "notification_email_test_email_sent": "", "notification_email_username_description": "", "notification_enable_email_notifications": "", - "notification_settings": "", + "notification_settings": "Paziņojumu iestatījumi", "notification_settings_description": "", "oauth_auto_launch": "", "oauth_auto_launch_description": "", "oauth_auto_register": "", "oauth_auto_register_description": "", - "oauth_button_text": "", - "oauth_client_id": "", - "oauth_client_secret": "", - "oauth_enable_description": "", + "oauth_button_text": "Pogas teksts", + "oauth_client_id": "Klienta ID", + "oauth_client_secret": "Klienta noslēpums", + "oauth_enable_description": "Pieslēgties ar OAuth", "oauth_issuer_url": "", "oauth_mobile_redirect_uri": "", "oauth_mobile_redirect_uri_override": "", "oauth_mobile_redirect_uri_override_description": "", + "oauth_profile_signing_algorithm": "Profila parakstīšanas algoritms", + "oauth_profile_signing_algorithm_description": "Lietotāja profila parakstīšanai izmantotais algoritms.", "oauth_scope": "", "oauth_settings": "OAuth", "oauth_settings_description": "", - "oauth_signing_algorithm": "", + "oauth_signing_algorithm": "Parakstīšanas algoritms", "oauth_storage_label_claim": "", "oauth_storage_label_claim_description": "", "oauth_storage_quota_claim": "", @@ -151,10 +168,14 @@ "password_enable_description": "", "password_settings": "", "password_settings_description": "", + "quota_size_gib": "Kvotas izmērs (GiB)", + "registration": "Administratora reģistrācija", + "require_password_change_on_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", + "scanning_library": "Skenē bibliotēku", "server_external_domain_settings": "", "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", + "server_settings": "Servera iestatījumi", + "server_settings_description": "Pārvaldīt servera iestatījumus", "server_welcome_message": "", "server_welcome_message_description": "", "sidecar_job_description": "", @@ -166,7 +187,8 @@ "storage_template_migration_job": "", "storage_template_settings": "", "storage_template_settings_description": "", - "theme_custom_css_settings": "", + "system_settings": "Sistēmas iestatījumi", + "theme_custom_css_settings": "Pielāgots CSS", "theme_custom_css_settings_description": "", "theme_settings": "", "theme_settings_description": "", @@ -174,16 +196,16 @@ "transcode_policy_description": "", "transcoding_acceleration_api": "", "transcoding_acceleration_api_description": "", - "transcoding_acceleration_nvenc": "", - "transcoding_acceleration_qsv": "", - "transcoding_acceleration_rkmpp": "", - "transcoding_acceleration_vaapi": "", + "transcoding_acceleration_nvenc": "NVENC (nepieciešams NVIDIA GPU)", + "transcoding_acceleration_qsv": "Quick Sync (nepieciešams 7. paaudzes vai jaunāks Intel procesors)", + "transcoding_acceleration_rkmpp": "RKMPP (tikai Rockchip SOC)", + "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "", "transcoding_accepted_audio_codecs_description": "", "transcoding_accepted_video_codecs": "", "transcoding_accepted_video_codecs_description": "", "transcoding_advanced_options_description": "", - "transcoding_audio_codec": "", + "transcoding_audio_codec": "Audio kodeks", "transcoding_audio_codec_description": "", "transcoding_bitrate_description": "", "transcoding_constant_quality_mode": "", @@ -216,7 +238,7 @@ "transcoding_target_resolution_description": "", "transcoding_temporal_aq": "", "transcoding_temporal_aq_description": "", - "transcoding_threads": "", + "transcoding_threads": "Pavedieni", "transcoding_threads_description": "", "transcoding_tone_mapping": "", "transcoding_tone_mapping_description": "", @@ -225,88 +247,112 @@ "transcoding_transcode_policy": "", "transcoding_two_pass_encoding": "", "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", + "transcoding_video_codec": "Video kodeks", "transcoding_video_codec_description": "", "trash_enabled_description": "", - "trash_number_of_days": "", + "trash_number_of_days": "Dienu skaits", "trash_number_of_days_description": "", "trash_settings": "", "trash_settings_description": "", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", + "user_management": "Lietotāju pārvaldība", + "user_password_has_been_reset": "Lietotāja parole ir atiestatīta:", + "user_restore_description": "{user} konts tiks atjaunots.", "user_settings": "", "user_settings_description": "", - "version_check_enabled_description": "", - "version_check_settings": "", + "version_check_enabled_description": "Ieslēgt versijas pārbaudi", + "version_check_implications": "Versiju pārbaudes funkcija ir atkarīga no periodiskas saziņas ar github.com", + "version_check_settings": "Versijas pārbaude", "version_check_settings_description": "", "video_conversion_job_description": "" }, - "admin_email": "", - "admin_password": "", - "administration": "", + "admin_email": "Administratora e-pasts", + "admin_password": "Administratora parole", + "administration": "Administrēšana", "advanced": "Papildu", - "album_added": "", + "album_added": "Albums pievienots", "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", + "album_cover_updated": "Albuma attēls atjaunināts", + "album_info_updated": "Albuma informācija atjaunināta", + "album_leave": "Pamest albumu?", + "album_name": "Albuma nosaukums", "album_options": "", - "album_updated": "", + "album_remove_user": "Noņemt lietotāju?", + "album_updated": "Albums atjaunināts", "album_updated_setting_description": "", - "albums": "", + "album_user_left": "Pameta {album}", + "album_user_removed": "Noņēma {user}", + "albums": "Albumi", "all": "Viss", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", + "all_albums": "Visi albumi", + "all_people": "Visi cilvēki", + "all_videos": "Visi video", + "allow_dark_mode": "Atļaut tumšo režīmu", + "allow_edits": "Atļaut labošanu", + "allow_public_user_to_download": "Atļaut lejupielādēt publiskiem lietotājiem", + "allow_public_user_to_upload": "Atļaut augšupielādēt publiskiem lietotājiem", + "anti_clockwise": "Pretēji pulksteņrādītāja virzienam", + "api_key": "API atslēga", + "api_key_description": "Šī vērtība tiks parādīta tikai vienu reizi. Nokopējiet to pirms loga aizvēršanas.", + "api_keys": "API atslēgas", "app_settings": "", "appears_in": "", "archive": "Arhīvs", "archive_or_unarchive_photo": "", + "archive_size": "Arhīva izmērs", "archived": "", + "are_these_the_same_person": "Vai šī ir tā pati persona?", + "asset_adding_to_album": "Pievieno albumam...", "asset_offline": "", + "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "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": "", "camera": "", "camera_brand": "", "camera_model": "", "cancel": "Atcelt", "cancel_search": "", - "cannot_merge_people": "", + "cannot_merge_people": "Nevar apvienot cilvēkus", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "Mainīt datumu", "change_expiration_time": "Izmainīt derīguma termiņu", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "Nomainīt Paroli", + "change_location": "Mainīt atrašanās vietu", + "change_name": "Mainīt nosaukumu", + "change_name_successfully": "Vārds veiksmīgi nomainīts", + "change_password": "Nomainīt paroli", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", + "choose_matching_people_to_merge": "Izvēlies atbilstošus cilvēkus apvienošanai", "city": "Pilsēta", "clear": "Notīrīt", - "clear_all": "", + "clear_all": "Notīrīt visu", "clear_message": "", "clear_value": "", - "close": "", - "collapse_all": "", + "close": "Aizvērt", + "collapse": "Sakļaut", + "collapse_all": "Sakļaut visu", + "color": "Krāsa", "color_theme": "", + "comment_deleted": "Komentārs dzēsts", "comment_options": "", "comments_are_disabled": "", "confirm": "Apstiprināt", "confirm_admin_password": "", - "confirm_password": "Apstiprināt Paroli", + "confirm_password": "Apstiprināt paroli", "contain": "", - "context": "", - "continue": "", + "context": "Konteksts", + "continue": "Turpināt", "copied_image_to_clipboard": "", "copy_error": "", "copy_file_path": "", @@ -324,8 +370,8 @@ "create_link": "Izveidot saiti", "create_link_to_share": "Izveidot kopīgošanas saiti", "create_new_person": "", - "create_new_user": "", - "create_user": "", + "create_new_user": "Izveidot jaunu lietotāju", + "create_user": "Izveidot lietotāju", "created": "", "current_device": "", "custom_locale": "", @@ -334,6 +380,7 @@ "date_after": "", "date_and_time": "Datums un Laiks", "date_before": "", + "date_of_birth_saved": "Dzimšanas datums veiksmīgi saglabāts", "date_range": "Datumu diapazons", "day": "", "default_locale": "", @@ -344,7 +391,7 @@ "delete_library": "", "delete_link": "", "delete_shared_link": "Dzēst Kopīgošanas saiti", - "delete_user": "", + "delete_user": "Dzēst lietotāju", "deleted_shared_link": "", "description": "Apraksts", "details": "INFORMĀCIJA", @@ -357,9 +404,11 @@ "display_order": "", "display_original_photos": "", "display_original_photos_setting_description": "", + "documentation": "Dokumentācija", "done": "Gatavs", "download": "Lejupielādēt", "downloading": "", + "duplicates": "Dublikāti", "duration": "", "durations": { "days": "", @@ -382,9 +431,11 @@ "edit_name": "Rediģēt vārdu", "edit_people": "", "edit_title": "", - "edit_user": "", + "edit_user": "Labot lietotāju", "edited": "", "editor": "", + "editor_close_without_save_prompt": "Izmaiņas netiks saglabātas", + "editor_close_without_save_title": "Aizvērt redaktoru?", "email": "E-pasts", "empty": "", "empty_album": "", @@ -395,6 +446,9 @@ "error": "", "error_loading_image": "", "errors": { + "cant_get_faces": "Nevar iegūt sejas", + "cant_search_people": "Neizdevās veikt peronu meklēšanu", + "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", "unable_to_add_partners": "", @@ -405,14 +459,14 @@ "unable_to_check_items": "", "unable_to_create_admin_account": "", "unable_to_create_library": "", - "unable_to_create_user": "", + "unable_to_create_user": "Neizdevās izveidot lietotāju", "unable_to_delete_album": "", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_user": "Neizdevās dzēst lietotāju", "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", + "unable_to_hide_person": "Neizdevās paslēpt personu", "unable_to_load_album": "", "unable_to_load_asset_activity": "", "unable_to_load_items": "", @@ -432,6 +486,7 @@ "unable_to_restore_trash": "", "unable_to_restore_user": "", "unable_to_save_album": "", + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", "unable_to_save_name": "", "unable_to_save_profile": "", "unable_to_save_settings": "", @@ -450,11 +505,11 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exit_slideshow": "", + "exit_slideshow": "Iziet no slīdrādes", "expand_all": "", "expire_after": "Derīguma termiņš beidzas pēc", "expired": "Derīguma termiņš beidzās", - "explore": "", + "explore": "Izpētīt", "extension": "", "external_libraries": "", "failed_to_get_people": "", @@ -471,6 +526,7 @@ "filetype": "", "filter_people": "", "fix_incorrect_match": "", + "folders": "Mapes", "force_re-scan_library_files": "", "forward": "", "general": "", @@ -480,53 +536,60 @@ "go_to_search": "", "go_to_share_page": "", "group_albums_by": "", - "has_quota": "", + "has_quota": "Ir kvota", "hide_gallery": "", + "hide_named_person": "Paslēpt personu {name}", "hide_password": "", - "hide_person": "", + "hide_person": "Paslēpt personu", "host": "", "hour": "", "image": "Attēls", "img": "", - "immich_logo": "", - "import_path": "", - "in_archive": "", - "include_archived": "Iekļaut Arhivētos", - "include_shared_albums": "", + "immich_logo": "Immich logo", + "import_from_json": "Importēt no JSON", + "import_path": "Importa ceļš", + "in_albums": "{count, plural, one {# albumā} other {# albumos}}", + "in_archive": "Arhīvā", + "include_archived": "Iekļaut arhivētos", + "include_shared_albums": "Iekļaut koplietotos albumus", "include_shared_partner_assets": "", "individual_share": "", - "info": "", + "info": "Informācija", "interval": { - "day_at_onepm": "", + "day_at_onepm": "Katru dienu 13.00", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "Katru dienu pusnaktī", + "night_at_twoam": "Katru dienu 2.00 naktī" }, - "invite_people": "", + "invite_people": "Ielūgt cilvēkus", "invite_to_album": "Uzaicināt albumā", "job_settings_description": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", + "jobs": "Darbi", + "keep": "Paturēt", + "keep_all": "Paturēt visus", + "keyboard_shortcuts": "Tastatūras saīsnes", + "language": "Valoda", + "language_setting_description": "Izvēlieties vēlamo valodu", + "last_seen": "Pēdējo reizi redzēts", + "latest_version": "Jaunākā versija", + "latitude": "Ģeogrāfiskais platums", + "leave": "Paturēt", "let_others_respond": "Ļaut citiem atbildēt", - "level": "", + "level": "Līmenis", "library": "Bibliotēka", "library_options": "", "light": "", "link_options": "", "link_to_oauth": "", "linked_oauth_account": "", - "list": "", - "loading": "", + "list": "Saraksts", + "loading": "Ielādē", "loading_search_results_failed": "", "log_out": "Izrakstīties", "log_out_all_devices": "", "login_has_been_disabled": "", - "look": "", + "longitude": "Ģeogrāfiskais garums", + "look": "Izskats", "loop_videos": "", "loop_videos_description": "Iespējot, lai automātiski videoklips tiktu cikliski palaists detaļu skatītājā.", "make": "Firma", @@ -537,70 +600,83 @@ "manage_your_api_keys": "", "manage_your_devices": "", "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", + "map": "Karte", + "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", + "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", - "media_type": "", - "memories": "", + "matches": "Atbilstības", + "media_type": "Multivides veids", + "memories": "Atmiņas", "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", + "memory": "Atmiņa", + "menu": "Izvēlne", + "merge": "Apvienot", + "merge_people": "Cilvēku apvienošana", + "merge_people_limit": "Vienlaikus var apvienot ne vairāk kā 5 sejas", + "merge_people_prompt": "Vai vēlies apvienot šos cilvēkus? Šī darbība ir neatgriezeniska.", + "merge_people_successfully": "Cilvēki veiksmīgi apvienoti", + "minimize": "Minimizēt", + "minute": "Minūte", + "missing": "Trūkstošie", "model": "Modelis", "month": "Mēnesis", - "more": "", + "more": "Vairāk", "moved_to_trash": "", - "my_albums": "", + "my_albums": "Mani albumi", "name": "Vārds", - "name_or_nickname": "", + "name_or_nickname": "Vārds vai iesauka", "never": "nekad", - "new_api_key": "", - "new_password": "Jauna Parole", - "new_person": "", - "new_user_created": "", + "new_album": "Jauns albums", + "new_api_key": "Jauna API atslēga", + "new_password": "Jaunā parole", + "new_person": "Jauna persona", + "new_user_created": "Izveidots jauns lietotājs", + "new_version_available": "PIEEJAMA JAUNA VERSIJA", "newest_first": "", "next": "Nākošais", - "next_memory": "", - "no": "", + "next_memory": "Nākamā atmiņa", + "no": "Nē", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", - "no_exif_info_available": "", + "no_assets_message": "NOKLIKŠĶINIET, LAI AUGŠUPIELĀDĒTU SAVU PIRMO FOTOATTĒLU", + "no_duplicates_found": "Dublikāti netika atrasti.", + "no_exif_info_available": "Nav pieejama exif informācija", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "Nav nosaukuma", + "no_places": "Nav atrašanās vietu", + "no_results": "Nav rezultātu", + "no_results_description": "Izmēģiniet sinonīmu vai vispārīgāku atslēgvārdu", "no_shared_albums_message": "", - "not_in_any_album": "", - "notes": "", - "notification_toggle_setting_description": "", + "not_in_any_album": "Nav nevienā albumā", + "notes": "Piezīmes", + "notification_toggle_setting_description": "Ieslēgt e-pasta paziņojumus", "notifications": "Paziņojumi", "notifications_setting_description": "", - "oauth": "", - "offline": "", - "ok": "", + "oauth": "OAuth", + "official_immich_resources": "Oficiālie Immich resursi", + "offline": "Bezsaistē", + "ok": "Labi", "oldest_first": "", - "online": "", - "only_favorites": "", + "online": "Tiešsaistē", + "only_favorites": "Tikai izlase", "only_refreshes_modified_files": "", - "open_the_search_filters": "", + "open_in_map_view": "Atvērt kartes skatā", + "open_in_openstreetmap": "Atvērt OpenStreetMap", + "open_the_search_filters": "Atvērt meklēšanas filtrus", "options": "Iestatījumi", + "or": "vai", "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", + "other": "Citi", + "other_devices": "Citas ierīces", + "other_variables": "Citi mainīgie", "owned": "Īpašumā", "owner": "Īpašnieks", "partner_sharing": "", "partners": "", "password": "Parole", - "password_does_not_match": "", + "password_does_not_match": "Parole nesakrīt", "password_required": "", "password_reset_success": "", "past_durations": { @@ -640,59 +716,86 @@ "primary": "", "profile_picture_set": "", "public_share": "", + "purchase_button_never_show_again": "Nekad vairs nerādīt", + "purchase_button_reminder": "Atgādināt man pēc 30 dienām", + "purchase_button_remove_key": "Noņemt atslēgu", + "purchase_button_select": "Izvēlēties", + "purchase_individual_description_2": "Atbalstītāja statuss", + "purchase_panel_title": "Atbalstīt projektu", + "purchase_remove_product_key": "Noņemt produkta atslēgu", + "purchase_server_description_1": "Visam serverim", + "purchase_server_description_2": "Atbalstītāja statuss", + "purchase_server_title": "Serveris", "range": "", "raw": "", "reaction_options": "", - "read_changelog": "", + "read_changelog": "Lasīt izmaiņu sarakstu", "recent": "", "recent_searches": "", "refresh": "", "refreshed": "", "refreshes_every_file": "", - "remove": "", + "remove": "Noņemt", + "remove_deleted_assets": "", "remove_from_album": "Noņemt no albuma", - "remove_from_favorites": "", + "remove_from_favorites": "Noņemt no izlases", "remove_from_shared_link": "", - "remove_offline_files": "", - "repair": "", + "remove_user": "Noņemt lietotāju", + "removed_api_key": "Noņēma API atslēgu: {name}", + "removed_from_archive": "Noņēma no arhīva", + "removed_from_favorites": "Noņēma no izlases", + "rename": "Pārsaukt", + "repair": "Remonts", "repair_no_results_message": "", - "replace_with_upload": "", + "replace_with_upload": "Aizstāt ar augšupielādi", "require_password": "", + "require_user_to_change_password_on_first_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "reset": "", "reset_password": "", "reset_people_visibility": "", "reset_settings_to_default": "", + "resolve_duplicates": "Atrisināt dublēšanās gadījumus", + "resolved_all_duplicates": "Visi dublikāti ir atrisināti", "restore": "Atjaunot", - "restore_user": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", + "restore_all": "Atjaunot visu", + "restore_user": "Atjaunot lietotāju", + "resume": "Turpināt", + "retry_upload": "Atkārtot augšupielādi", + "review_duplicates": "Pārskatīt dublikātus", + "role": "Loma", + "role_editor": "Redaktors", + "role_viewer": "Skatītājs", "save": "Saglabāt", - "saved_profile": "", - "saved_settings": "", + "saved_api_key": "API atslēga saglabāta", + "saved_profile": "Profils saglabāts", + "saved_settings": "Iestatījumi saglabāti", "say_something": "Teikt kaut ko", "scan_all_libraries": "", "scan_all_library_files": "", "scan_new_library_files": "", "scan_settings": "", "search": "Meklēt", - "search_albums": "", + "search_albums": "Meklēt albumus", "search_by_context": "", + "search_by_filename_example": "piemēram, IMG_1234.JPG vai PNG", "search_camera_make": "", "search_camera_model": "", "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", + "search_no_people": "Nav cilvēku", + "search_no_people_named": "Nav cilvēku ar vārdu \"{name}\"", + "search_people": "Meklēt cilvēkus", "search_places": "", "search_state": "", "search_timezone": "", "search_type": "", "search_your_photos": "Meklēt Jūsu fotoattēlus", "searching_locales": "", - "second": "", - "select_album_cover": "", + "second": "Sekunde", + "select_album_cover": "Izvēlieties albuma vāciņu", "select_all": "", + "select_all_duplicates": "Atlasīt visus dublikātus", "select_avatar_color": "", "select_face": "", "select_featured_photo": "", @@ -702,11 +805,13 @@ "selected": "", "send_message": "", "server": "", - "server_stats": "", + "server_online": "Serveris tiešsaistē", + "server_stats": "Servera statistika", + "server_version": "Servera versija", "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", + "set_date_of_birth": "Iestatīt dzimšanas datumu", "set_profile_picture": "", "set_slideshow_to_fullscreen": "", "settings": "Iestatījumi", @@ -715,13 +820,16 @@ "shared": "Kopīgots", "shared_by": "", "shared_by_you": "", - "shared_links": "Kopīgotas Saites", + "shared_links": "Kopīgotās saites", "sharing": "Kopīgošana", "sharing_sidebar_description": "", - "show_album_options": "", - "show_file_location": "", - "show_gallery": "", - "show_hidden_people": "", + "show_album_options": "Rādīt albuma iespējas", + "show_albums": "Rādīt albumus", + "show_all_people": "Rādīt visus cilvēkus", + "show_and_hide_people": "Rādīt un slēpt cilvēkus", + "show_file_location": "Rādīt faila atrašanās vietu", + "show_gallery": "Rādīt galeriju", + "show_hidden_people": "Rādīt paslēptos cilvēkus", "show_in_timeline": "", "show_in_timeline_setting_description": "", "show_keyboard_shortcuts": "", @@ -731,67 +839,86 @@ "show_person_options": "", "show_progress_bar": "", "show_search_options": "", + "show_supporter_badge": "Atbalstītāja nozīmīte", + "show_supporter_badge_description": "Rādīt atbalstītāja nozīmīti", "shuffle": "", "sign_up": "", - "size": "", + "size": "Izmērs", "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", - "sort_albums_by": "", + "slideshow": "Slīdrāde", + "slideshow_settings": "Slīdrādes iestatījumi", + "sort_albums_by": "Kārtot albumus pēc...", + "sort_created": "Izveides datums", + "sort_items": "Vienību skaits", + "sort_modified": "Izmaiņu datums", + "sort_oldest": "Vecākā fotogrāfija", + "sort_recent": "Nesenākā fotogrāfija", + "sort_title": "Nosaukums", + "source": "Avots", "stack": "Steks", "stack_selected_photos": "", "stacktrace": "", "start_date": "", "state": "Štats", - "status": "", + "status": "Statuss", "stop_motion_photo": "", "stop_photo_sharing": "Beigt kopīgot jūsu fotogrāfijas?", - "storage": "", + "storage": "Uzglabāšanas vieta", "storage_label": "", - "submit": "", + "storage_usage": "{used} no {available} izmantoti", + "submit": "Iesniegt", "suggestions": "Ieteikumi", - "sunrise_on_the_beach": "", + "sunrise_on_the_beach": "Saullēkts pludmalē", + "support": "Atbalsts", + "support_and_feedback": "Atbalsts un atsauksmes", "swap_merge_direction": "", - "sync": "", + "sync": "Sinhronizēt", "template": "", "theme": "Dizains", "theme_selection": "", "theme_selection_description": "", + "they_will_be_merged_together": "Tās tiks apvienotas", "time_based_memories": "", "timezone": "Laika zona", - "toggle_settings": "", + "to_archive": "Arhivēt", + "to_change_password": "Mainīt paroli", + "toggle_settings": "Pārslēgt iestatījumus", "toggle_theme": "", "toggle_visibility": "", - "total_usage": "", + "total_usage": "Kopējais lietojums", "trash": "Atkritne", "trash_all": "", "trash_no_results_message": "", "type": "", "unarchive": "Atarhivēt", "unarchived": "", - "unfavorite": "Noņemt no Izlases", - "unhide_person": "", + "unfavorite": "Noņemt no izlases", + "unhide_person": "Atcelt personas slēpšanu", "unknown": "", "unknown_album": "", - "unknown_year": "", + "unknown_year": "Nezināms gads", + "unlimited": "Neierobežots", "unlink_oauth": "", "unlinked_oauth_account": "", + "unnamed_album": "Albums bez nosaukuma", + "unsaved_change": "Nesaglabāta izmaiņa", "unselect_all": "", "unstack": "At-Stekot", "up_next": "", "updated_password": "", "upload": "Augšupielādēt", "upload_concurrency": "", + "upload_status_duplicates": "Dublikāti", "upload_status_errors": "Kļūdas", "upload_status_uploaded": "Augšupielādēts", "url": "", - "usage": "", + "usage": "Lietojums", "user": "Lietotājs", "user_id": "Lietotāja ID", - "user_usage_detail": "", + "user_usage_detail": "Informācija par lietotāju lietojumu", "username": "", "users": "Lietotāji", - "utilities": "", + "utilities": "Rīki", "validate": "", "variables": "", "version": "Versija", @@ -804,10 +931,11 @@ "view_next_asset": "", "view_previous_asset": "", "viewer": "", - "waiting": "", + "waiting": "Gaida", "week": "", "welcome_to_immich": "", "year": "", + "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" } diff --git a/web/src/lib/i18n/el.json b/i18n/mfa.json similarity index 100% rename from web/src/lib/i18n/el.json rename to i18n/mfa.json diff --git a/web/src/lib/i18n/et.json b/i18n/mk.json similarity index 100% rename from web/src/lib/i18n/et.json rename to i18n/mk.json diff --git a/web/src/lib/i18n/mn.json b/i18n/mn.json similarity index 71% rename from web/src/lib/i18n/mn.json rename to i18n/mn.json index 54a4710a03..231c84e4cf 100644 --- a/web/src/lib/i18n/mn.json +++ b/i18n/mn.json @@ -1,32 +1,41 @@ { - "account": "", - "acknowledge": "", - "action": "", - "actions": "", - "active": "", - "activity": "", - "add": "", - "add_a_description": "", - "add_a_location": "", - "add_a_name": "", - "add_a_title": "", + "about": "Тухай", + "account": "Бүртгэл", + "account_settings": "Бүртгэлийн тохиргоо", + "acknowledge": "Ойлголоо", + "action": "Үйлдэл", + "actions": "Үйлдлүүд", + "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_location": "Байршил оруулах", + "add_more_users": "Өөр хэрэглэгчид нэмэх", + "add_partner": "Хамтрагч нэмэх", "add_path": "", - "add_photos": "", + "add_photos": "Зураг нэмэх", "add_to": "", - "add_to_album": "", - "add_to_shared_album": "", + "add_to_album": "Цомогт оруулах", + "add_to_shared_album": "Нээлттэй албумд оруулах", + "added_to_archive": "Архивд оруулах", + "added_to_favorites": "Дуртай зурганд нэмэх", + "added_to_favorites_count": "Дуртай зурагнуудад {count, number} нэмэгдлээ", "admin": { - "authentication_settings": "", - "authentication_settings_description": "", + "authentication_settings": "Танин нэвтрэлт тохиргоо", + "authentication_settings_description": "Нууц үгийн удирдлага, OAuth болон бусад танин нэвтрэлтийн тохиргоо", + "authentication_settings_disable_all": "Бүх нэвтрэх аргуудыг идэвхигүй болгохдоо итгэлтэй байна уу? Нэвтрэх үйлдэл бүрэн идэвхигүй болно.", + "check_all": "Бүгдийг сонгох", "crontab_guru": "", "disable_login": "", "disabled": "", "duplicate_detection_job_description": "", + "face_detection": "Нүүр илрүүлэх", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -35,15 +44,16 @@ "image_preview_format": "", "image_preview_resolution": "", "image_preview_resolution_description": "", - "image_quality": "", + "image_quality": "Чанар", "image_quality_description": "", "image_settings": "", "image_settings_description": "", "image_thumbnail_format": "", "image_thumbnail_resolution": "", "image_thumbnail_resolution_description": "", - "job_settings": "", + "job_settings": "Ажлын тохиргоо", "job_settings_description": "", + "job_status": "Ажлын төлөв", "library_cron_expression": "", "library_cron_expression_presets": "", "library_scanning": "", @@ -62,11 +72,13 @@ "machine_learning_duplicate_detection": "", "machine_learning_duplicate_detection_enabled_description": "", "machine_learning_duplicate_detection_setting_description": "", - "machine_learning_enabled_description": "", - "machine_learning_facial_recognition": "", - "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", - "machine_learning_facial_recognition_model_description": "", + "machine_learning_enabled": "Машин сургалт идэвхжүүлэх", + "machine_learning_enabled_description": "Идэвхгүй болгосон үед доорх тохиргооноос хамаарахгүйгээр бүх машин сургалтын боломж идэвхгүй болно.", + "machine_learning_facial_recognition": "Нүүр танилт", + "machine_learning_facial_recognition_description": "Зураг дээрх хүмүүсийн нүүрийг илрүүлж, таньж, бүлэглэнэ", + "machine_learning_facial_recognition_model": "Нүүр танилтын загвар", + "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": "", @@ -89,7 +101,7 @@ "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Газрын зураг", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -210,14 +222,17 @@ "transcoding_two_pass_encoding_setting_description": "", "transcoding_video_codec": "", "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", + "trash_enabled_description": "Хогийн сав идэвхжүүлэх", + "trash_number_of_days": "Хоногийн тоо", + "trash_number_of_days_description": "Хогийн саванд хэд хоног хадгалаад бүр мөсөн устгах вэ", + "trash_settings": "Хогийн савны тохиргоо", + "trash_settings_description": "Хогийн савны тохиргоог өөрчлөх", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", - "user_settings": "", + "user_management": "Хэрэглэгчийн удирдлага", + "user_password_has_been_reset": "Хэрэглэгчийн нууц үг шинээр тохируулагдлаа:", + "user_restore_description": "{user}-н бүртгэл сэргэнэ.", + "user_settings": "Хэрэглэгчийн тохиргоо", "user_settings_description": "", "version_check_enabled_description": "", "version_check_settings": "", @@ -226,57 +241,70 @@ }, "admin_email": "", "admin_password": "", - "administration": "", + "administration": "Админ", "advanced": "", - "album_added": "", + "album_added": "Цомог нэмэгдлээ", "album_added_notification_setting_description": "", "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", + "album_info_updated": "Цомгийн мэлээлэл шинэчлэгдлээ", + "album_leave": "Цомгоос гарах уу?", + "album_leave_confirmation": "Та {album} цомгоос гарахдаа итгэлтэй байна уу?", + "album_name": "Цомгийн нэр", + "album_options": "Цомгийн тохиргоо", + "album_remove_user": "Хэрэглэгч хасах уу?", + "album_remove_user_confirmation": "{user} хэрэглэгчийг хасахдаа итгэлтэй байна уу?", "album_updated": "", "album_updated_setting_description": "", - "albums": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", + "albums": "Цомгууд", + "all": "Бүгд", + "all_albums": "Бүх цомог", + "all_people": "Бүх хүн", + "all_videos": "Бүх бичлэг", + "allow_dark_mode": "Харанхуй горим зөвшөөрөх", + "allow_edits": "Засварлалт зөвшөөрөх", + "api_key": "API түлхүүр", + "api_key_description": "Энэ утга зөвхөн ганц л удаа харагдана. Цонхоо хаахаас өмнө хуулж аваарай.", + "api_key_empty": "Таны API түлхүүрийн нэр хоосон байж болохгүй", + "api_keys": "API түлхүүрүүд", + "app_settings": "Апп-н тохиргоо", "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "archive": "Архив", + "archive_or_unarchive_photo": "Зургийг архивт хийх эсвэл гаргах", + "archive_size": "Архивын хэмжээ", + "archive_size_description": "Татах үеийн архивын хэмжээг тохируулах (GiB-р)", "archived": "", + "asset_added_to_album": "Цомогт нэмсэн", + "asset_adding_to_album": "Цомогт нэмж байна...", "asset_offline": "", "assets": "", "authorized_devices": "", "back": "", "backward": "", "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", + "buy": "Immich худалдаж авах", + "camera": "Камер", + "camera_brand": "Камерын үйлдвэр", + "camera_model": "Камерын загвар", "cancel": "Цуцлах", - "cancel_search": "", + "cancel_search": "Хайлт цуцлах", "cannot_merge_people": "", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "Огноо өөрчлөх", "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", + "change_location": "Байршил өөрчлөх", + "change_name": "Нэр өөрчлөх", + "change_name_successfully": "Нэр амжилттай өөрчлөгдлөө", + "change_password": "Нууц үг өөрчлөх", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", - "city": "", - "clear": "", - "clear_all": "", + "city": "Хот", + "clear": "Цэвэрлэх", + "clear_all": "Бүгдийг цэвэрлэх", "clear_message": "", "clear_value": "", "close": "", @@ -371,7 +399,7 @@ "email": "", "empty": "", "empty_album": "", - "empty_trash": "", + "empty_trash": "Хогийн сав хоослох", "enable": "", "enabled": "", "end_date": "", @@ -392,7 +420,7 @@ "unable_to_delete_album": "", "unable_to_delete_asset": "", "unable_to_delete_user": "", - "unable_to_empty_trash": "", + "unable_to_empty_trash": "Хогийн савыг хоослож чадсангүй", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", "unable_to_hide_person": "", @@ -412,7 +440,7 @@ "unable_to_reset_password": "", "unable_to_resolve_duplicate": "", "unable_to_restore_assets": "", - "unable_to_restore_trash": "", + "unable_to_restore_trash": "Хогийн савнаас гаргаж чадсангүй", "unable_to_restore_user": "", "unable_to_save_album": "", "unable_to_save_name": "", @@ -437,13 +465,13 @@ "expand_all": "", "expire_after": "", "expired": "", - "explore": "", + "explore": "Эрж олох", "extension": "", "external_libraries": "", "failed_to_get_people": "", "favorite": "", "favorite_or_unfavorite_photo": "", - "favorites": "", + "favorites": "Дуртай", "feature": "", "feature_photo_updated": "", "featurecollection": "", @@ -485,7 +513,7 @@ "night_at_midnight": "", "night_at_twoam": "" }, - "invite_people": "", + "invite_people": "Хүмүүс урих", "invite_to_album": "", "job_settings_description": "", "jobs": "", @@ -497,7 +525,7 @@ "leave": "", "let_others_respond": "", "level": "", - "library": "", + "library": "Зургийн сан", "library_options": "", "light": "", "link_options": "", @@ -551,9 +579,9 @@ "no": "", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "Энд дарж та эхний зургаа хуулж үзэх үү", "no_exif_info_available": "", - "no_explore_results_message": "", + "no_explore_results_message": "Зураг хуулж оруулсаны дараа ашиглах боломжтой болно.", "no_favorites_message": "", "no_libraries_message": "", "no_name": "", @@ -570,7 +598,7 @@ "ok": "", "oldest_first": "", "online": "", - "only_favorites": "", + "only_favorites": "Зөвхөн дуртай зурагнууд", "only_refreshes_modified_files": "", "open_the_search_filters": "", "options": "", @@ -597,7 +625,7 @@ "pause_memories": "", "paused": "", "pending": "", - "people": "", + "people": "Хүмүүс", "people_sidebar_description": "", "perform_library_tasks": "", "permanent_deletion_warning": "", @@ -608,7 +636,7 @@ "photos_from_previous_years": "", "pick_a_location": "", "place": "", - "places": "", + "places": "Байршилууд", "play": "", "play_memories": "", "play_motion_photo": "", @@ -633,10 +661,12 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", - "remove_from_favorites": "", + "remove_from_favorites": "Дуртай зурагнуудаас хасах", "remove_from_shared_link": "", - "remove_offline_files": "", + "removed_from_favorites": "Дуртай зурагнуудаас хасагдсан", + "removed_from_favorites_count": "Дуртай зурагнуудаас {count, plural, other {Removed #}} хасагдлаа", "repair": "", "repair_no_results_message": "", "replace_with_upload": "", @@ -667,11 +697,11 @@ "search_country": "", "search_for_existing_person": "", "search_people": "", - "search_places": "", + "search_places": "Байршил хайх", "search_state": "", "search_timezone": "", "search_type": "", - "search_your_photos": "", + "search_your_photos": "Зурагнуудаасаа хайлт хийх", "searching_locales": "", "second": "", "select_album_cover": "", @@ -685,6 +715,7 @@ "selected": "", "send_message": "", "server": "", + "server_online": "Сервер Онлайн", "server_stats": "", "set": "", "set_as_album_cover": "", @@ -699,7 +730,7 @@ "shared_by": "", "shared_by_you": "", "shared_links": "", - "sharing": "", + "sharing": "Хуваалцах", "sharing_sidebar_description": "", "show_album_options": "", "show_file_location": "", @@ -715,6 +746,7 @@ "show_progress_bar": "", "show_search_options": "", "shuffle": "", + "sign_out": "Гарах", "sign_up": "", "size": "", "skip_to_content": "", @@ -728,8 +760,9 @@ "state": "", "status": "", "stop_motion_photo": "", - "storage": "", + "storage": "Дискний багтаамж", "storage_label": "", + "storage_usage": "Нийт {available} боломжтойгоос {used} хэрэглэсэн", "submit": "", "suggestions": "", "sunrise_on_the_beach": "", @@ -745,7 +778,7 @@ "toggle_theme": "", "toggle_visibility": "", "total_usage": "", - "trash": "", + "trash": "Хогийн сав", "trash_all": "", "trash_no_results_message": "", "type": "", @@ -762,7 +795,7 @@ "unstack": "", "up_next": "", "updated_password": "", - "upload": "", + "upload": "Зураг хуулах", "upload_concurrency": "", "url": "", "usage": "", @@ -771,23 +804,27 @@ "user_usage_detail": "", "username": "", "users": "", - "utilities": "", + "utilities": "Багаж хэрэгсэл", "validate": "", "variables": "", "version": "", "video": "", "video_hover_setting_description": "", "videos": "", - "view_all": "", - "view_all_users": "", + "view_all": "Бүгдийг харах", + "view_all_users": "Бүх хэрэглэгчийг харах", "view_links": "", "view_next_asset": "", "view_previous_asset": "", "viewer": "", - "waiting": "", - "week": "", - "welcome_to_immich": "", - "year": "", - "yes": "", - "zoom_image": "" + "waiting": "Хүлээж байна", + "warning": "Анхааруулга", + "week": "Долоо хоног", + "welcome": "Тавтай морил", + "welcome_to_immich": "Тавтай морилно уу", + "year": "Он", + "years_ago": "{years, plural, one {# year} other {# years}} өмнө", + "yes": "Тийм", + "you_dont_have_any_shared_links": "Танд хуваалцсан холбоос алга", + "zoom_image": "Зургийг томруулж харах" } diff --git a/web/src/lib/i18n/te.json b/i18n/mr.json similarity index 100% rename from web/src/lib/i18n/te.json rename to i18n/mr.json diff --git a/i18n/ms.json b/i18n/ms.json new file mode 100644 index 0000000000..cec6f00273 --- /dev/null +++ b/i18n/ms.json @@ -0,0 +1,122 @@ +{ + "about": "Tentang", + "account": "Akaun", + "account_settings": "Tetapan Akaun", + "acknowledge": "Akui", + "action": "Tindakan", + "actions": "Tindakan", + "active": "Aktif", + "activity": "Aktiviti", + "activity_changed": "Aktiviti {enabled, select, true {enabled} other {disabled}}", + "add": "Tambah", + "add_a_description": "Tambah penerangan", + "add_a_location": "Tambah lokasi", + "add_a_name": "Tambah nama", + "add_a_title": "Tambah tajuk", + "add_exclusion_pattern": "Tambahkan corak pengecualian", + "add_import_path": "Tambahkan laluan import", + "add_location": "Tambah lokasi", + "add_more_users": "Tambah user lagi", + "add_partner": "Tambah rakan", + "add_path": "Tambah laluan", + "add_photos": "Tambah gambar", + "add_to": "Tambah ke...", + "add_to_album": "Tambah ke album", + "add_to_shared_album": "Tambah ke album yang dikongsi", + "added_to_archive": "Tambah ke arkib", + "added_to_favorites": "Ditambah pada favorit", + "added_to_favorites_count": "Menambahkan {count, number} ke favorit", + "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.", + "authentication_settings": "Tetapan Pengesahan", + "authentication_settings_description": "Urus kata laluan, OAuth dan tetapan pengesahan lain", + "authentication_settings_disable_all": "Adakah anda pasti mahu melumpuhkan semua kaedah log masuk? Log masuk akan dilumpuhkan sepenuhnya.", + "authentication_settings_reenable": "Untuk menghidupkan semula, guna Arahan Pelayan.", + "background_task_job": "Tugas Latar Belakang", + "check_all": "Tanda Semua", + "cleared_jobs": "Kerja telah dibersihkan untuk: {job}", + "config_set_by_file": "Konfigurasi kini ditetapkan oleh fail konfigurasi", + "confirm_delete_library": "Adakah anda pasti mahu memadamkan {library}?", + "confirm_delete_library_assets": "Adakah anda pasti mahu memadamkan pustaka ini? Ini akan memadam {count, plural, one {# aset yang terkandung} other {semua # aset yang terkandung}} daripada Immich dan tidak boleh dibuat asal. Fail akan kekal pada disk.", + "confirm_email_below": "Untuk mengesahkan, sila taip \"{email}\" dibawah", + "confirm_reprocess_all_faces": "Adakah anda pasti mahu memproses semula semua wajah? Ini juga akan membersihkan orang bernama.", + "confirm_user_password_reset": "Adakah anda pasti mahu menetapkan semula kata laluan {user}?", + "create_job": "Cipta tugas", + "disable_login": "Lumpuhkan fungsi log masuk", + "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mengesan imej yang serupa. Bergantung pada Carian Pintar", + "exclusion_pattern_description": "Corak pengecualian membolehkan anda mengabaikan fail dan folder semasa mengimbas pustaka anda. Ini berguna jika anda mempunyai folder yang mengandungi fail yang anda tidak mahu import, seperti fail RAW.", + "external_library_created_at": "Pustaka luaran (dicipta pada {date})", + "external_library_management": "Pengurusan Perpustakaan Luar", + "face_detection": "Pengesanan wajah", + "face_detection_description": "Kesan wajah dalam aset menggunakan pembelajaran mesin. Untuk video, hanya lakaran kecil dipertimbangkan. \"Segar Semula\" memproses semula semua aset. \"Tetapkan Semula\" juga mengosongkan semua data wajah semasa. \"Hilang\" baris gilir aset yang belum diproses lagi. Wajah yang dikesan akan beratur untuk Pengecaman Wajah selepas Pengesanan Wajah selesai, menghimpunkannya kepada orang sedia ada atau baharu.", + "facial_recognition_job_description": "Kumpulan wajah yang dikesan ke dalam orang. Langkah ini dijalankan selepas Pengesanan Wajah selesai. \"Tetapkan semula\" mengelompokkan semula semua wajah. \"Hilang\" jalankan proses pada wajah yang tidak mempunyai orang yang ditetapkan.", + "failed_job_command": "Perintah {command} gagal untuk kerja: {job}", + "force_delete_user_warning": "AMARAN: Ini akan mengalih keluar pengguna dan semua aset dengan serta-merta. Ia tidak boleh dibuat asal dan fail tidak boleh dipulihkan.", + "forcing_refresh_library_files": "Memaksa muat semula semua fail perpustakaan", + "image_format": "Format", + "image_format_description": "WebP menghasilkan fail yang lebih kecil daripada JPEG, tetapi lebih perlahan untuk mengekod.", + "image_prefer_embedded_preview": "Cadangkan pratonton terbenam", + "image_prefer_embedded_preview_setting_description": "Gunakan pratonton terbenam dalam foto RAW sebagai input kepada pemprosesan imej apabila tersedia. Cara ini boleh menghasilkan warna yang lebih tepat untuk sesetengah imej, tetapi kualiti pratonton bergantung pada kamera dan imej mungkin mempunyai lebih banyak artifak mampatan.", + "image_prefer_wide_gamut": "Cadangkan warna gamut yang luas", + "image_prefer_wide_gamut_setting_description": "Gunakan Paparan P3 untuk lakaran kenit. Ini lebih baik mengekalkan kerancakan imej dengan ruang warna yang luas, tetapi imej mungkin kelihatan berbeza pada peranti lama dengan versi penyemak imbas lama. Imej sRGB disimpan sebagai sRGB untuk mengelakkan peralihan warna.", + "image_preview_description": "Imej bersaiz sederhana dengan metadata yang dilucutkan, digunakan semasa melihat aset tunggal dan untuk pembelajaran mesin", + "image_preview_quality_description": "Kualiti pratonton dari 1-100. Lebih tinggi adalah lebih baik, tetapi menghasilkan fail yang lebih besar dan boleh mengurangkan responsif apl. Menetapkan nilai yang rendah boleh menjejaskan kualiti pembelajaran mesin.", + "image_preview_title": "Tetapan Pratonton", + "image_quality": "Kualiti", + "image_resolution": "Resolusi", + "image_resolution_description": "Resolusi yang lebih tinggi boleh meningkatkan ketajaman imej tetapi mengambil masa yang lebih lama untuk mengekod, mempunyai saiz fail yang lebih besar dan boleh mengurangkan responsif apl.", + "image_settings": "Tetapan Imej", + "image_settings_description": "Urus kualiti dan resolusi imej yang dihasilkan", + "image_thumbnail_description": "Lakaran kecil dengan metadata yang dilucutkan, digunakan semasa melihat kumpulan foto seperti garis masa utama", + "image_thumbnail_quality_description": "Kualiti lakaran kenit daripada 1-100. Lebih tinggi adalah lebih baik, tetapi menghasilkan fail yang lebih besar dan boleh mengurangkan responsif apl.", + "image_thumbnail_title": "Tetapan Lakaran Kenit", + "job_concurrency": "Konkurensi {job}", + "job_created": "Tugas yang dicipta", + "job_not_concurrency_safe": "Konkurensi tugas ini tidak selamat.", + "job_settings": "Tetapan Tugas", + "job_settings_description": "Urus konkurensi tugas", + "job_status": "Status Tugasan", + "jobs_delayed": "{jobCount, plural, other {# tertangguh}}", + "jobs_failed": "{jobCount, plural, other {# gagal}}", + "library_created": "Pustaka dicipta: {library}", + "library_cron_expression": "Ungkapan Cron", + "library_cron_expression_description": "Tetapkan selang pengimbasan menggunakan format cron. Untuk maklumat lanjut sila rujuk cth. Crontab Guru", + "library_cron_expression_presets": "Pratetap ungkapan Cron", + "library_deleted": "Pustaka dipadamkan", + "library_import_path_description": "Tentukan folder untuk diimport. Folder ini, termasuk subfolder, akan diimbas untuk imej dan video.", + "library_scanning": "Pengimbasan Berkala", + "library_scanning_description": "Konfigurasikan pengimbasan perpustakaan berkala", + "library_scanning_enable_description": "Dayakan pengimbasan perpustakaan berkala", + "library_settings": "Perpustakaan Luaran", + "library_settings_description": "Urus tetapan perpustakaan luaran", + "library_tasks_description": "Laksanakan tugas perpustakaan", + "library_watching_enable_description": "Perhatikan perpustakaan luaran untuk perubahan fail", + "library_watching_settings": "Perhati perpustakaan (EKSPERIMEN)", + "library_watching_settings_description": "Perhati fail yang diubah secara automatik", + "logging_enable_description": "Dayakan pengelogan", + "logging_level_description": "Apabila didayakan, tahap log yang hendak digunakan.", + "logging_settings": "Log", + "machine_learning_clip_model": "Model CLIP", + "machine_learning_clip_model_description": "Nama model CLIP disenaraikan di sini. Ambil perhatian bahawa anda mesti menjalankan semula tugas 'Carian Pintar' untuk semua imej selepas menukar model.", + "machine_learning_duplicate_detection": "Pengesanan Pendua", + "machine_learning_duplicate_detection_enabled": "Dayakan pengesanan pendua", + "machine_learning_duplicate_detection_enabled_description": "Jika dilumpuhkan, aset yang betul-betul serupa masih akan dinyahduakan.", + "machine_learning_duplicate_detection_setting_description": "Gunakan pembenaman CLIP untuk mencari kemungkinan pendua", + "machine_learning_enabled": "Dayakan pembelajaran mesin", + "machine_learning_enabled_description": "Jika dilumpuhkan, semua ciri Pembelajaran Mesin akan dilumpuhkan tanpa mengira tetapan di bawah.", + "machine_learning_facial_recognition": "Pengecaman Wajah", + "machine_learning_facial_recognition_description": "Mengesan, mengecam dan mengumpulkan wajah dalam imej", + "machine_learning_facial_recognition_model": "Model pengecaman wajah", + "machine_learning_facial_recognition_model_description": "Model disenaraikan dalam susunan saiz menurun. Model yang lebih besar adalah lebih perlahan dan menggunakan lebih banyak memori, tetapi menghasilkan hasil yang lebih baik. Ambil perhatian bahawa anda mesti menjalankan semula kerja Pengesanan Wajah untuk semua imej apabila menukar model.", + "machine_learning_facial_recognition_setting": "Dayakan pengecaman wajah", + "machine_learning_facial_recognition_setting_description": "Jika dilumpuhkan, imej tidak akan dikodkan untuk pengecaman wajah dan tidak akan mengisi bahagian Orang dalam halaman Teroka.", + "machine_learning_max_detection_distance": "Jarak pengesanan maksimum", + "machine_learning_max_detection_distance_description": "Jarak maksimum antara dua imej untuk menganggapnya sebagai pendua, antara 0.001-0.1. Nilai yang lebih tinggi akan mengesan lebih banyak pendua, tetapi mungkin menghasilkan positif palsu.", + "machine_learning_max_recognition_distance": "Jarak pengecaman maksimum", + "machine_learning_max_recognition_distance_description": "Jarak maksimum antara dua muka untuk dianggap sebagai orang yang sama, antara 0-2. Menurunkan ini boleh menghalang pelabelan dua orang sebagai orang yang sama, manakala menaikkannya boleh menghalang pelabelan orang yang sama sebagai dua orang yang berbeza. Ambil perhatian bahawa adalah lebih mudah untuk menggabungkan dua orang daripada membelah satu orang kepada dua, jadi silap pada bahagian ambang yang lebih rendah apabila boleh.", + "machine_learning_min_detection_score": "Skor pengesanan minimum", + "machine_learning_min_detection_score_description": "Skor keyakinan minimum untuk wajah dikesan dari 0-1. Nilai yang lebih rendah akan mengesan lebih banyak muka tetapi mungkin menghasilkan positif palsu.", + "machine_learning_min_recognized_faces": "Minimum mengenali wajah" + } +} diff --git a/web/src/lib/i18n/nb_NO.json b/i18n/nb_NO.json similarity index 95% rename from web/src/lib/i18n/nb_NO.json rename to i18n/nb_NO.json index 357f2b0b3f..ea508d1b7e 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Legg til delt album", "added_to_archive": "Lagt til i arkiv", "added_to_favorites": "Lagt til i favoritter", - "added_to_favorites_count": "Lagt til {count} 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/**\".", "authentication_settings": "Autentiserings innstillinger", @@ -127,6 +127,8 @@ "manage_log_settings": "Administrer logginnstillinger", "map_dark_style": "Mørk stil", "map_enable_description": "Aktiver kartfunksjoner", + "map_gps_settings": "Kart & GPS Innstillinger", + "map_gps_settings_description": "Administrer innstillinger for kart og GPS (Reversert geokoding)", "map_light_style": "Lys stil", "map_reverse_geocoding": "Omvendt geokoding", "map_reverse_geocoding_enable_description": "Aktiver omvendt geokoding", @@ -136,6 +138,8 @@ "map_style_description": "URL til et style.json-karttema", "metadata_extraction_job": "Hent metadata", "metadata_extraction_job_description": "Hent metadatainformasjon fra hver fil, for eksempel GPS-posisjon og oppløsning", + "metadata_settings": "Metadatainnstillinger", + "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", @@ -144,7 +148,7 @@ "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", "note_unlimited_quota": "Merk: Skriv inn 0 for ubegrenset kvote", "notification_email_from_address": "Fra adresse", - "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", + "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", "notification_email_host_description": "Verten til e-posts serveren (f.eks. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer sertifikatfeil", "notification_email_ignore_certificate_errors_description": "Ignorer valideringsfeil for TLS-sertifikat (ikke anbefalt)", @@ -194,7 +198,7 @@ "refreshing_all_libraries": "Oppdaterer alle biblioteker", "registration": "Administrator registrering", "registration_description": "Siden du er den første brukeren på systemet, vil du bli utnevnt til administrator og ha ansvar for administrative oppgaver. Du vil også opprette eventuelle nye brukere.", - "removing_offline_files": "Fjerner frakoblede filer", + "removing_deleted_files": "Fjerner frakoblede filer", "repair_all": "Reparer alle", "repair_matched_items": "Samsvarte med {count, plural, one {# element} other {# elementer}}", "repaired_items": "Reparerte {count, plural, one {# item} other {# items}}", @@ -220,10 +224,10 @@ "storage_template_hash_verification_enabled": "Hash verifisering aktivert", "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_description": "Bruk gjeldende {mal} 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_job": "Migreringsjobb for lagringsmal", - "storage_template_more_details": "For mer informasjon om denne funksjonen, se Storage Template og dens implications", + "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.", "storage_template_path_length": "Omtrentlig stilengdebegrensning: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmal", @@ -246,6 +250,8 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Godkjente lydkodeker", "transcoding_accepted_audio_codecs_description": "Velg hvilke lydkodeker som ikke trenger å transkodes. Brukes kun for visse transkode retningslinjer.", + "transcoding_accepted_containers": "aksepterte kontainere", + "transcoding_accepted_containers_description": "Velg hvilke containerformater som ikke trenger å bli remuxet til MP4. Brukes kun for visse transkoderingspolicyer.", "transcoding_accepted_video_codecs": "Godkjente videokodeker", "transcoding_accepted_video_codecs_description": "Velg hvilke videokodeker som ikke trenger å transkodes. Brukes kun for visse transcoding-regler.", "transcoding_advanced_options_description": "Valg som de fleste brukere ikke trenger å endre", @@ -261,7 +267,7 @@ "transcoding_hardware_acceleration": "Maskinvareakselerasjon", "transcoding_hardware_acceleration_description": "Eksperimentell; mye raskere, men vil ha lavere kvalitet ved samme bithastighet", "transcoding_hardware_decoding": "Maskinvaredekoding", - "transcoding_hardware_decoding_setting_description": "Gjelder bare for NVENC og RKMPP. Aktiverer ende-til-ende akselerasjon i stedet for bare akselerering av koding. Vil ikke fungere med alle videoer.", + "transcoding_hardware_decoding_setting_description": "Gjelder bare for NVENC,QSV og RKMPP. Aktiverer ende-til-ende akselerasjon i stedet for bare akselerering av koding. Vil ikke fungere med alle videoer.", "transcoding_hevc_codec": "HEVC-codec", "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.", @@ -316,6 +322,7 @@ "user_settings_description": "Administrer brukerinnstillinger", "user_successfully_removed": "Brukeren {email} er nå fjernet.", "version_check_enabled_description": "Aktiver periodiske forespørsler til GitHub for å sjekke etter nye utgivelser", + "version_check_implications": "Versjonssjekkfunksjonen baserer seg på periodisk kommunikasjon med github.com", "version_check_settings": "Versjonssjekk", "version_check_settings_description": "Aktiver/deaktiver varsel om ny versjon", "video_conversion_job": "Transkod videoer", @@ -331,6 +338,7 @@ "album_added_notification_setting_description": "Motta en e-postvarsling når du legges til i et delt album", "album_cover_updated": "Albumomslag oppdatert", "album_delete_confirmation": "Er du sikker på at du vil slette albumet {album}?\nHvis dette albumet er delt, vil ikke andre brukere ha tilgang til det lenger.", + "album_delete_confirmation_description": "Hvis dette albumet deles, vil andre brukere miste tilgangen til dette.", "album_info_updated": "Albuminformasjon oppdatert", "album_leave": "Forlate album?", "album_leave_confirmation": "Er du sikker på at du vil forlate {album}?", @@ -354,6 +362,7 @@ "allow_edits": "Tillat redigering", "allow_public_user_to_download": "Tillat uautentiserte brukere å laste ned", "allow_public_user_to_upload": "Tillat uautentiserte brukere å laste opp", + "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", @@ -377,16 +386,20 @@ "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...", "assets": "Filer", "assets_added_count": "Lagt til {count, plural, one {# element} other {# elementer}}", "assets_moved_to_trash": "Flyttet {count, plural, one {# fil} other {# filer}} til papirkurv", + "assets_restore_confirmation": "Er du sikker på at du vil gjenopprette alle slettede eiendeler? Denne handlingen kan ikke angres!", "authorized_devices": "Autoriserte enheter", "back": "Tilbake", "backward": "Bakover", + "birthdate_saved": "Fødselsdato er lagret vellykket.", + "birthdate_set_description": "Fødelsdatoen er brukt for å beregne alderen til denne personen ved tidspunktet til bildet.", "blurred_background": "Uskarp bakgrunn", - "bulk_delete_duplicates_confirmation": "Er du sikker på at du vil slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe og vil permament slette alle andre duplikater. Du kan ikke angre denne handlingen!", + "bulk_delete_duplicates_confirmation": "Er du sikker på at du vil slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe og vil permanent slette alle andre duplikater. Du kan ikke angre denne handlingen!", "bulk_keep_duplicates_confirmation": "Er du sikker på at du vil beholde {count} dupliserte filer? Dette vil løse alle dupliserte grupper uten å slette noe.", "bulk_trash_duplicates_confirmation": "Er du sikker på ønsker å slette {count} dupliserte filer? Dette vil beholde største filen fra hver gruppe, samt slette alle andre duplikater.", "camera": "Kamera", @@ -395,6 +408,7 @@ "cancel": "Avbryt", "cancel_search": "Avbryt søk", "cannot_merge_people": "Kan ikke slå sammen personer", + "cannot_undo_this_action": "Du kan ikke gjøre om denne handlingen!", "cannot_update_the_description": "Kan ikke oppdatere beskrivelsen", "cant_apply_changes": "Kan ikke gjennomføre endringene", "cant_get_faces": "Kan ikke hente ansikter", @@ -405,7 +419,8 @@ "change_location": "Endre sted", "change_name": "Endre navn", "change_name_successfully": "Navneendring vellykket", - "change_password": "Endre passord", + "change_password": "Endre Passord", + "change_password_description": "Dette er enten første gang du logger inn i systemet, eller det har blitt gjort en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", "change_your_password": "Endre passordet ditt", "changed_visibility_successfully": "Endret synlighet vellykket", "check_all": "Sjekk alle", @@ -414,12 +429,15 @@ "city": "By", "clear": "Tøm", "clear_all": "Tøm alt", + "clear_all_recent_searches": "Fjern alle nylige søk", "clear_message": "Fjern melding", "clear_value": "Fjern verdi", "close": "Lukk", "collapse_all": "Kollaps alt", "color_theme": "Fargetema", + "comment_deleted": "Kommentar slettet", "comment_options": "Kommentaralternativer", + "comments_and_likes": "Kommentarer & likes", "comments_are_disabled": "Kommentarer er deaktivert", "confirm": "Bekreft", "confirm_admin_password": "Bekreft administratorpassord", @@ -445,7 +463,9 @@ "create_library": "Opprett Bibliotek", "create_link": "Opprett link", "create_link_to_share": "Opprett delelink", + "create_link_to_share_description": "La alle med lenken se de(t) valgte bildet/bildene", "create_new_person": "Opprett ny person", + "create_new_person_hint": "Tildel valgte eiendeler til en ny person", "create_new_user": "Opprett ny bruker", "create_user": "Opprett Bruker", "created": "Opprettet", @@ -456,8 +476,10 @@ "date_after": "Dato etter", "date_and_time": "Dato og tid", "date_before": "Dato før", + "date_of_birth_saved": "Fødselsdatoen ble lagret vellykket", "date_range": "Datoområde", "day": "Dag", + "deduplicate_all": "De-dupliser alle", "default_locale": "Standard språkinnstilling", "default_locale_description": "Formater datoer og tall basert på nettleserens språkinnstilling", "delete": "Slett", @@ -482,6 +504,7 @@ "display_order": "Visningsrekkefølge", "display_original_photos": "Vis opprinnelige bilder", "display_original_photos_setting_description": "Foretrekk å vise det opprinnelige bildet når du ser på en fil i stedet for miniatyrbilder når den opprinnelige filen er kompatibel med nettet. Dette kan føre til tregere visning av bilder.", + "do_not_show_again": "Ikke vis denne meldingen igjen", "done": "Ferdig", "download": "Last ned", "download_settings": "Last ned", @@ -569,8 +592,8 @@ "unable_to_remove_album_users": "Kan ikke fjerne brukere fra album", "unable_to_remove_api_key": "Kan ikke fjerne API-nøkkel", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Kan ikke fjerne offlinefiler", "unable_to_remove_library": "Kan ikke fjerne bibliotek", - "unable_to_remove_offline_files": "Kan ikke fjerne offlinefiler", "unable_to_remove_partner": "Kan ikke fjerne partner", "unable_to_remove_reaction": "Kan ikke fjerne reaksjon", "unable_to_remove_user": "", @@ -821,10 +844,10 @@ "refreshed": "Oppdatert", "refreshes_every_file": "Oppdaterer alle filer", "remove": "Fjern", + "remove_deleted_assets": "Fjern fra frakoblede filer", "remove_from_album": "Fjern fra album", "remove_from_favorites": "Fjern fra favoritter", "remove_from_shared_link": "Fjern fra delt lenke", - "remove_offline_files": "Fjern fra frakoblede filer", "removed_api_key": "Fjernet API-nøkkel: {name}", "rename": "Gi nytt navn", "repair": "Reparer", diff --git a/web/src/lib/i18n/nl.json b/i18n/nl.json similarity index 91% rename from web/src/lib/i18n/nl.json rename to i18n/nl.json index 9a73d1b3a1..d437ae17f7 100644 --- a/web/src/lib/i18n/nl.json +++ b/i18n/nl.json @@ -28,6 +28,7 @@ "added_to_favorites_count": "{count, number} toegevoegd aan favorieten", "admin": { "add_exclusion_pattern_description": "Uitsluitingspatronen toevoegen. Globbing met *, ** en ? wordt ondersteund. Om alle bestanden in een map met de naam \"Raw\" te negeren, gebruik \"**/Raw/**\". Om alle bestanden die eindigen op \".tif\" te negeren, gebruik \"**/*.tif\". Om een absoluut pad te negeren, gebruik \"/path/to/ignore/**\".", + "asset_offline_description": "Deze asset uit een externe bibliotheek is niet meer beschikbaar op de schijf en is naar de prullenbak verplaatst. Als het bestand binnen de bibliotheek is verplaatst, controleer dan je tijdlijn voor de nieuwe bijbehorende asset. Om dit bestand te herstellen, zorg ervoor dat het onderstaande bestandspad toegankelijk is voor Immich en scan de bibliotheek opnieuw.", "authentication_settings": "Authenticatie-instellingen", "authentication_settings_description": "Wachtwoord, OAuth, en andere authenticatie-instellingen beheren", "authentication_settings_disable_all": "Weet je zeker dat je alle inlogmethoden wilt uitschakelen? Inloggen zal volledig worden uitgeschakeld.", @@ -41,6 +42,7 @@ "confirm_email_below": "Typ hieronder \"{email}\" ter bevestiging", "confirm_reprocess_all_faces": "Weet je zeker dat je alle gezichten opnieuw wilt verwerken? Hiermee worden ook alle mensen gewist.", "confirm_user_password_reset": "Weet u zeker dat je het wachtwoord van {user} wilt resetten?", + "create_job": "Taak maken", "crontab_guru": "Crontab Guru", "disable_login": "Inloggen uitschakelen", "disabled": "Uitgeschakeld", @@ -49,27 +51,37 @@ "external_library_created_at": "Externe bibliotheek (gemaakt op {date})", "external_library_management": "Externe bibliotheek beheren", "face_detection": "Gezichtsdetectie", - "face_detection_description": "Detecteer gezichten in assets met behulp van machine learning. Voor video's wordt alleen de thumbnail gebruikt. \"Alle\" verwerkt alle assets (opnieuw). \"Missend\" plaatst assets in de wachtrij die nog niet zijn verwerkt. Gedetecteerde gezichten worden in de wachtrij geplaatst voor gezichtsherkenning nadat gezichtsdetectie is voltooid, waarbij ze worden gegroepeerd in bestaande of nieuwe mensen.", - "facial_recognition_job_description": "Groepeer gedetecteerde gezichten tot mensen. Deze stap wordt uitgevoerd nadat gezichtsdetectie is voltooid. \"Alle\" (her-)clustert alle gezichten. \"Missend\" plaatst gezichten in de wachtrij waaraan geen persoon is toegewezen.", + "face_detection_description": "Detecteer gezichten in assets met behulp van machine learning. Voor video's wordt alleen de thumbnail gebruikt. \"Resetten\" verwerkt alle assets (opnieuw). \"Reset\" verwijdert daarnaast alle huidige gezichtgegevens. \"Missend\" plaatst assets in de wachtrij die nog niet zijn verwerkt. Gedetecteerde gezichten worden in de wachtrij geplaatst voor gezichtsherkenning nadat gezichtsdetectie is voltooid, waarbij ze worden gegroepeerd in bestaande of nieuwe mensen.", + "facial_recognition_job_description": "Groepeer gedetecteerde gezichten tot mensen. Deze stap wordt uitgevoerd nadat gezichtsdetectie is voltooid. \"Resetten\" (her-)clustert alle gezichten. \"Missend\" plaatst gezichten in de wachtrij waaraan geen persoon is toegewezen.", "failed_job_command": "Commando {command} mislukt voor taak: {job}", "force_delete_user_warning": "WAARSCHUWING: Hiermee worden de gebruiker en alle assets onmiddellijk verwijderd. Dit kan niet ongedaan worden gemaakt en de bestanden kunnen niet worden hersteld.", "forcing_refresh_library_files": "Geforceerd vernieuwen van alle bibliotheekbestanden", + "image_format": "Formaat", "image_format_description": "WebP produceert kleinere bestanden dan JPEG, maar is langzamer om te verwerken.", "image_prefer_embedded_preview": "Ingebedde voorbeeldafbeelding gebruiken", "image_prefer_embedded_preview_setting_description": "Ingebedde voorbeeldafbeelding van RAW bestanden gebruiken als invoer voor beeldverwerking wanneer beschikbaar. Dit kan preciezere kleuren produceren voor sommige afbeeldingen, maar de kwaliteit van het voorbeeld is afhankelijk van de camera en de afbeelding kan mogelijk meer compressie-artefacten hebben.", "image_prefer_wide_gamut": "Voorkeur geven aan wide gamut", "image_prefer_wide_gamut_setting_description": "Display P3 gebruiken voor voorbeeldafbeeldingen. Dit behoudt de levendigheid van afbeeldingen met brede kleurruimtes beter, maar afbeeldingen kunnen er anders uitzien op oude apparaten met een oude browserversie. sRGB-afbeeldingen blijven sRGB gebruiken om kleurverschuivingen te vermijden.", + "image_preview_description": "Middelgrote afbeelding met verwijderde metadata, gebruikt bij het bekijken van een enkele asset en voor machine learning", "image_preview_format": "Voorbeeldformaat", + "image_preview_quality_description": "Voorbeeldafbeelding kwaliteit van 1-100. Hoger is beter, maar produceert grotere bestanden en kan de app vertragen. Een lage waarde kan de kwaliteit van machine learning beïnvloeden.", "image_preview_resolution": "Voorbeeldresolutie", "image_preview_resolution_description": "Gebruikt bij het tonen van een enkele foto en voor machine learning. Hogere resoluties kunnen meer detail behouden maar duren langer om te verwerken, hebben hogere bestandsgrootte, en kunnen de applicatie langzamer maken.", + "image_preview_title": "Voorbeeldafbeelding instellingen", "image_quality": "Kwaliteit", "image_quality_description": "Afbeeldingskwaliteit van 1-100. Een hoger getal zorgt voor een betere fotokwaliteit, maar produceert grotere bestanden. Dit heeft effect op voorbeeldfoto's en thumbnails.", + "image_resolution": "Resolutie", + "image_resolution_description": "Hogere resoluties behouden meer details, maar verhogen de coderingstijd, bestandsgrootte en kunnen de app vertragen.", "image_settings": "Afbeeldings instellingen", "image_settings_description": "Beheer de kwaliteit en resolutie van gegenereerde afbeeldingen", + "image_thumbnail_description": "Kleine thumbnail zonder metadata, gebruikt voor het bekijken van groepen met foto's zoals de tijdlijn", "image_thumbnail_format": "Thumbnail bestandsformaat", + "image_thumbnail_quality_description": "Thumbnail kwaliteit van 1-100. Hoger is beter, maar produceert grotere bestanden en kan de app vertragen.", "image_thumbnail_resolution": "Thumbnail resolutie", "image_thumbnail_resolution_description": "Gebruikt wanneer groepen foto's bekeken worden (hoofdtijdslijn, album, enzo). Hogere resoluties kunnen meer detail behouden maar duren langer om te verwerken, hebben hogere bestandsgrootte, en kunnen de applicatie langzamer maken.", + "image_thumbnail_title": "Thumbnail instellingen", "job_concurrency": "{job} gelijktijdigheid", + "job_created": "Taak aangemaakt", "job_not_concurrency_safe": "Deze taak kan niet gelijktijdig worden uitgevoerd.", "job_settings": "Achtergrondtaak-instellingen", "job_settings_description": "Beheer gelijktijdige taken", @@ -129,16 +141,21 @@ "map_enable_description": "Kaartfuncties inschakelen", "map_gps_settings": "Kaart & GPS Instellingen", "map_gps_settings_description": "Beheer kaart & GPS (omgekeerde geocodering) instellingen", + "map_implications": "De kaartfunctie is afhankelijk van een externe service (tiles.immich.cloud)", "map_light_style": "Lichte stijl", "map_manage_reverse_geocoding_settings": "Beheer omgekeerde geocodering instellingen", "map_reverse_geocoding": "Omgekeerde geocodering", "map_reverse_geocoding_enable_description": "Omgekeerde geocodering inschakelen", "map_reverse_geocoding_settings": "Instellingen voor omgekeerde geocodering", - "map_settings": "Kaart instellingen", + "map_settings": "Kaart", "map_settings_description": "Beheer kaartinstellingen", "map_style_description": "URL naar een style.json kaartthema", "metadata_extraction_job": "Metadata ophalen", - "metadata_extraction_job_description": "Metadata ophalen van iedere asset, zoals GPS en resolutie", + "metadata_extraction_job_description": "Metadata ophalen van iedere asset, zoals GPS, gezichten en resolutie", + "metadata_faces_import_setting": "Gezichten importeren inschakelen", + "metadata_faces_import_setting_description": "Gezichten importeren uit EXIF-gegevens van afbeeldingen en sidecar bestanden", + "metadata_settings": "Metadata instellingen", + "metadata_settings_description": "Beheer metadata instellingen", "migration_job": "Migratie", "migration_job_description": "Migreer thumbnails voor assets en gezichten naar de nieuwste mapstructuur", "no_paths_added": "Geen paden toegevoegd", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "LET OP: Dit kan later niet meer worden gewijzigd!", "note_unlimited_quota": "Opmerking: voer 0 in voor onbeperkt", "notification_email_from_address": "Adres afzender", - "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", + "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", "notification_email_host_description": "Host van de e-mailserver (bijv. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Negeer certificaatfouten", "notification_email_ignore_certificate_errors_description": "Negeer TLS certificaat validatiefouten (niet aanbevolen)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "Uitgever URL", "oauth_mobile_redirect_uri": "Omleidings URI voor mobiel", "oauth_mobile_redirect_uri_override": "Omleidings URI voor mobiele app overschrijven", - "oauth_mobile_redirect_uri_override_description": "Inschakelen wanneer 'app.immich:/' een ongeldige omleidings-URI is.", + "oauth_mobile_redirect_uri_override_description": "Inschakelen wanneer de OAuth-provider geen mobiele URI toestaat, zoals '{callback}'", "oauth_profile_signing_algorithm": "Algoritme voor profielondertekening", "oauth_profile_signing_algorithm_description": "Algoritme voor het ondertekenen van het gebruikersprofiel.", "oauth_scope": "Scope", @@ -193,19 +210,22 @@ "password_settings": "Inloggen met wachtwoord", "password_settings_description": "Beheer instellingen voor inloggen met wachtwoord", "paths_validated_successfully": "Alle paden succesvol gevalideerd", + "person_cleanup_job": "Persoon opschoning", "quota_size_gib": "Opslaglimiet (GiB)", - "refreshing_all_libraries": "Alle bibliotheken vernieuwen", + "refreshing_all_libraries": "Alle bibliotheken aan het vernieuwen", "registration": "Beheerder registratie", "registration_description": "Omdat je de eerste gebruiker in het systeem bent, word je toegewezen als beheerder en ben je verantwoordelijk voor administratieve taken. Extra gebruikers kunnen door jou worden aangemaakt.", - "removing_offline_files": "Offline bestanden verwijderen", + "removing_deleted_files": "Offline bestanden verwijderen", "repair_all": "Repareer alle", "repair_matched_items": "Overeenkomend {count, plural, one {# item} other {# items}}", "repaired_items": "Gerepareerd {count, plural, one {# item} other {# items}}", "require_password_change_on_login": "Vereisen dat de gebruiker het wachtwoord wijzigt bij de eerste keer inloggen", "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", "scanning_library_for_changed_files": "Bibliotheek scannen op gewijzigde bestanden", "scanning_library_for_new_files": "Bibliotheek scannen op nieuwe bestanden", + "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)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Beheer de mapstructuur en bestandsnaam van geüploade bestanden", "storage_template_user_label": "{label} is het opslaglabel van de gebruiker", "system_settings": "Systeeminstellingen", + "tag_cleanup_job": "Tag opschoning", "theme_custom_css_settings": "Aangepaste CSS", "theme_custom_css_settings_description": "Met Cascading Style Sheets kan het ontwerp van Immich worden aangepast.", "theme_settings": "Thema instellingen", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Hardware acceleratie", "transcoding_hardware_acceleration_description": "Experimenteel; veel sneller, maar zal een lagere kwaliteit hebben bij dezelfde bitrate", "transcoding_hardware_decoding": "Hardware decodering", - "transcoding_hardware_decoding_setting_description": "Geldt alleen voor NVENC, QSV en RKMPP. Maakt end-to-end versnelling mogelijk in plaats van alleen de codering te versnellen. Werkt mogelijk niet op alle video's.", + "transcoding_hardware_decoding_setting_description": "Maakt end-to-end versnelling mogelijk in plaats van alleen de codering te versnellen. Werkt mogelijk niet op alle video's.", "transcoding_hevc_codec": "HEVC codec", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Voorkeur hardwareapparaat", "transcoding_preferred_hardware_device_description": "Geldt alleen voor VAAPI en QSV. Stelt de dri node in die wordt gebruikt voor hardwaretranscodering.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Compressiesnelheid. Langzamere presets produceren kleinere bestanden en verhogen de kwaliteit bij het targeten van een bepaalde bitrate. VP9 negeert snelheden boven `faster`.", + "transcoding_preset_preset_description": "Compressiesnelheid. Langzamere presets produceren kleinere bestanden en verhogen de kwaliteit bij het targeten van een bepaalde bitrate. VP9 negeert snelheden boven 'faster'.", "transcoding_reference_frames": "Reference frames", "transcoding_reference_frames_description": "Het aantal frames om naar te verwijzen bij het comprimeren van een bepaald frame. Hogere waarden verbeteren de compressie-efficiëntie, maar vertragen de codering. Bij 0 wordt deze waarde automatisch ingesteld.", "transcoding_required_description": "Alleen video's die geen geaccepteerd formaat hebben", @@ -307,6 +328,7 @@ "trash_settings_description": "Beheer prullenbak instellingen", "untracked_files": "Niet bijgehouden bestanden", "untracked_files_description": "Deze bestanden worden niet bijgehouden door de applicatie. Dit kan het resultaat zijn van een mislukte verplaatsing, onderbroken upload of een bug", + "user_cleanup_job": "Gebruiker opschoning", "user_delete_delay": "Het account en de assets van {user} worden over {delay, plural, one {# dag} other {# dagen}} permanent verwijderd.", "user_delete_delay_settings": "Verwijder vertraging", "user_delete_delay_settings_description": "Aantal dagen na verwijdering om het account en de assets van een gebruiker permanent te verwijderen. De taak voor het verwijderen van gebruikers wordt om middernacht uitgevoerd om te controleren of gebruikers verwijderd kunnen worden. Wijzigingen in deze instelling worden bij de volgende uitvoering meegenomen.", @@ -320,7 +342,8 @@ "user_settings": "Gebruikersinstellingen", "user_settings_description": "Gebruikersinstellingen beheren", "user_successfully_removed": "Gebruiker {email} is succesvol verwijderd.", - "version_check_enabled_description": "Periodieke verzoeken aan GitHub inschakelen om te controleren op nieuwe releases", + "version_check_enabled_description": "Versiecontrole inschakelen", + "version_check_implications": "De versiecontrole is afhankelijk van periodieke communicatie met github.com", "version_check_settings": "Versiecontrole", "version_check_settings_description": "Melding voor een nieuwe versie in-/uitschakelen", "video_conversion_job": "Transcodeer video's", @@ -336,7 +359,8 @@ "album_added": "Album toegevoegd", "album_added_notification_setting_description": "Ontvang een e-mailmelding wanneer je aan een gedeeld album wordt toegevoegd", "album_cover_updated": "Album cover is bijgewerkt", - "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?\nAls dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", + "album_delete_confirmation": "Weet je zeker dat je het album {album} wilt verwijderen?", + "album_delete_confirmation_description": "Als dit album gedeeld is, hebben andere gebruikers er geen toegang meer toe.", "album_info_updated": "Albumgegevens bijgewerkt", "album_leave": "Album verlaten?", "album_leave_confirmation": "Weet je zeker dat je {album} wilt verlaten?", @@ -360,6 +384,7 @@ "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", + "anti_clockwise": "Linksom", "api_key": "API sleutel", "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", @@ -381,8 +406,9 @@ "asset_has_unassigned_faces": "Asset heeft niet-toegewezen gezichten", "asset_hashing": "Hashen...", "asset_offline": "Asset offline", - "asset_offline_description": "Deze asset is offline. Immich kan de bestandslocatie niet openen. Controleer of de asset beschikbaar is en scan de bibliotheek opnieuw.", + "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...", "assets": "Assets", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "{count, plural, one {# asset} other {# assets}} verplaatst naar prullenbak", "assets_permanently_deleted_count": "{count, plural, one {# asset} other {# assets}} permanent verwijderd", "assets_removed_count": "{count, plural, one {# asset} other {# assets}} verwijderd", - "assets_restore_confirmation": "Weet je zeker dat je alle verwijderde assets wilt herstellen? Je kunt deze actie niet ongedaan maken!", + "assets_restore_confirmation": "Weet je zeker dat je alle verwijderde assets wilt herstellen? Je kunt deze actie niet ongedaan maken! Offline assets kunnen op deze manier niet worden hersteld.", "assets_restored_count": "{count, plural, one {# asset} other {# assets}} hersteld", "assets_trashed_count": "{count, plural, one {# asset} other {# assets}} naar prullenbak verplaatst", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets waren}} al onderdeel van het album", @@ -405,6 +431,7 @@ "birthdate_saved": "Geboortedatum succesvol opgeslagen", "birthdate_set_description": "De geboortedatum wordt gebruikt om de leeftijd van deze persoon op het moment van de foto te berekenen.", "blurred_background": "Vervaagde achtergrond", + "bugs_and_feature_requests": "Bugs & functieverzoeken", "build": "Build", "build_image": "Build image", "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!", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Wis alle recente zoekopdrachten", "clear_message": "Bericht wissen", "clear_value": "Waarde wissen", + "clockwise": "Rechtsom", "close": "Sluiten", "collapse": "Inklappen", "collapse_all": "Alles inklappen", + "color": "Kleur", "color_theme": "Kleurthema", "comment_deleted": "Opmerking verwijderd", "comment_options": "Opties voor opmerkingen", @@ -477,6 +506,8 @@ "create_new_person": "Nieuwe persoon aanmaken", "create_new_person_hint": "Geselecteerde assets toewijzen aan een nieuwe persoon", "create_new_user": "Nieuwe gebruiker aanmaken", + "create_tag": "Tag aanmaken", + "create_tag_description": "Maak een nieuwe tag. Voor geneste tags, voer het volledige pad van de tag in, inclusief schuine strepen.", "create_user": "Gebruiker aanmaken", "created": "Aangemaakt", "current_device": "Huidig apparaat", @@ -500,13 +531,17 @@ "delete_library": "Verwijder bibliotheek", "delete_link": "Verwijder link", "delete_shared_link": "Verwijder gedeelde link", + "delete_tag": "Tag verwijderen", + "delete_tag_confirmation_prompt": "Weet je zeker dat je de tag {tagName} wilt verwijderen?", "delete_user": "Verwijder gebruiker", "deleted_shared_link": "Gedeelde link verwijderd", + "deletes_missing_assets": "Verwijdert assets die ontbreken op de schijf", "description": "Beschrijving", "details": "Details", "direction": "Richting", "disabled": "Uitgeschakeld", "disallow_edits": "Geen bewerkingen toestaan", + "discord": "Discord", "discover": "Zoeken", "dismiss_all_errors": "Negeer alle fouten", "dismiss_error": "Negeer fout", @@ -515,8 +550,11 @@ "display_original_photos": "Toon originele foto's", "display_original_photos_setting_description": "Geef de voorkeur aan het weergeven van de originele foto bij het bekijken van een asset in plaats van thumbnails wanneer de originele asset webcompatibel is. Dit kan resulteren in lagere weergavesnelheid van foto's.", "do_not_show_again": "Laat dit bericht niet meer zien", + "documentation": "Documentatie", "done": "Klaar", "download": "Downloaden", + "download_include_embedded_motion_videos": "Ingesloten video's", + "download_include_embedded_motion_videos_description": "Voeg video's toe die ingesloten zijn in bewegende foto's als een apart bestand", "download_settings": "Downloaden", "download_settings_description": "Beheer instellingen voor het downloaden van assets", "downloading": "Downloaden", @@ -546,10 +584,15 @@ "edit_location": "Locatie bewerken", "edit_name": "Naam bewerken", "edit_people": "Mensen bewerken", + "edit_tag": "Tag bewerken", "edit_title": "Titel bewerken", "edit_user": "Gebruiker bewerken", "edited": "Bijgewerkt", "editor": "Bewerker", + "editor_close_without_save_prompt": "De wijzigingen worden niet opgeslagen", + "editor_close_without_save_title": "Editor sluiten?", + "editor_crop_tool_h2_aspect_ratios": "Beeldverhoudingen", + "editor_crop_tool_h2_rotation": "Rotatie", "email": "E-mailadres", "empty": "", "empty_album": "Leeg album", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Kan het aantal opmerkingen niet ophalen", "unable_to_get_shared_link": "Kan gedeelde link niet ophalen", "unable_to_hide_person": "Kan persoon niet verbergen", + "unable_to_link_motion_video": "Kan bewegende video niet verbinden", "unable_to_link_oauth_account": "Kan OAuth account niet koppelen", "unable_to_load_album": "Kan album niet laden", "unable_to_load_asset_activity": "Kan asset activiteit niet laden", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Kan API sleutel niet verwijderen", "unable_to_remove_assets_from_shared_link": "Kan assets niet verwijderen uit gedeelde link", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Kan offline bestanden niet verwijderen", "unable_to_remove_library": "Kan bibliotheek niet verwijderen", - "unable_to_remove_offline_files": "Kan offline bestanden niet verwijderen", "unable_to_remove_partner": "Kan partner niet verwijderen", "unable_to_remove_reaction": "Kan reactie niet verwijderen", "unable_to_remove_user": "", @@ -679,6 +723,7 @@ "unable_to_submit_job": "Kan taak niet uitvoeren", "unable_to_trash_asset": "Kan asset niet naar prullenbak verplaatsen", "unable_to_unlink_account": "Kan account niet ontkoppelen", + "unable_to_unlink_motion_video": "Kan bewegende video niet los maken", "unable_to_update_album_cover": "Kan album cover niet bijwerken", "unable_to_update_album_info": "Kan albumgegevens niet bijwerken", "unable_to_update_library": "Kan bibliotheek niet bijwerken", @@ -699,6 +744,7 @@ "expired": "Verlopen", "expires_date": "Verloopt {date}", "explore": "Verkennen", + "explorer": "Verkenner", "export": "Exporteren", "export_as_json": "Exporteren als JSON", "extension": "Extensie", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Uitgelichte afbeelding bijgewerkt", "featurecollection": "", + "features": "Functies", + "features_setting_description": "Beheer de app functies", "file_name": "Bestandsnaam", "file_name_or_extension": "Bestandsnaam of extensie", "filename": "Bestandsnaam", @@ -720,6 +768,8 @@ "filter_people": "Filter op mensen", "find_them_fast": "Vind ze snel op naam door te zoeken", "fix_incorrect_match": "Onjuiste overeenkomst corrigeren", + "folders": "Mappen", + "folders_feature_description": "Bladeren door de mapweergave van de foto's en video's op het bestandssysteem", "force_re-scan_library_files": "Forceer herscan van alle bibliotheekbestanden", "forward": "Vooruit", "general": "Algemeen", @@ -819,6 +869,7 @@ "license_trial_info_4": "Overweeg een licentie te kopen om de verdere ontwikkeling van de service te ondersteunen", "light": "Licht", "like_deleted": "Like verwijderd", + "link_motion_video": "verbind bewegende video", "link_options": "Opties voor link", "link_to_oauth": "Koppel OAuth", "linked_oauth_account": "Gekoppeld OAuth account", @@ -837,6 +888,7 @@ "look": "Uiterlijk", "loop_videos": "Video's herhalen", "loop_videos_description": "Inschakelen om video's automatisch te herhalen in de detailweergave.", + "main_branch_warning": "U gebruikt een ontwikkelingsversie. Wij raden u ten zeerste aan een releaseversie te gebruiken!", "make": "Merk", "manage_shared_links": "Beheer gedeelde links", "manage_sharing_with_partners": "Beheer delen met partners", @@ -906,12 +958,14 @@ "notifications": "Meldingen", "notifications_setting_description": "Beheer meldingen", "oauth": "OAuth", + "official_immich_resources": "Officiële Immich bronnen", "offline": "Offline", "offline_paths": "Offline paden", "offline_paths_description": "Deze resultaten kunnen te wijten zijn aan het handmatig verwijderen van bestanden die geen deel uitmaken van een externe bibliotheek.", "ok": "Ok", "oldest_first": "Oudste eerst", "onboarding": "Onboarding", + "onboarding_privacy_description": "De volgende (optionele) functies zijn afhankelijk van externe services en kunnen op elk moment worden uitgeschakeld in de beheerdersinstellingen.", "onboarding_storage_template_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].", "onboarding_theme_description": "Kies een kleurenthema voor de applicatie. Dit kun je later wijzigen in je instellingen.", "onboarding_welcome_description": "Laten we de applicatie instellen met enkele veelgebruikte instellingen.", @@ -919,7 +973,8 @@ "online": "Online", "only_favorites": "Alleen favorieten", "only_refreshes_modified_files": "Vernieuwt alleen gewijzigde bestanden", - "open_in_openstreetmap": "Openen met OpenStreetMap", + "open_in_map_view": "Openen in kaartweergave", + "open_in_openstreetmap": "Openen in OpenStreetMap", "open_the_search_filters": "Open de zoekfilters", "options": "Opties", "or": "of", @@ -953,6 +1008,7 @@ "pending": "In behandeling", "people": "Mensen", "people_edits_count": "{count, plural, one {# persoon} other {# mensen}} bijgewerkt", + "people_feature_description": "Bladeren door foto's en video's gegroepeerd op personen", "people_sidebar_description": "Toon een link naar Mensen in de zijbalk", "perform_library_tasks": "", "permanent_deletion_warning": "Waarschuwing voor permanent verwijderen", @@ -985,10 +1041,11 @@ "previous_memory": "Vorige herinnering", "previous_or_next_photo": "Vorige of volgende foto", "primary": "Primair", + "privacy": "Privacy", "profile_image_of_user": "Profielfoto van {user}", "profile_picture_set": "Profielfoto ingesteld.", "public_album": "Openbaar album", - "public_share": "Publieke deellink", + "public_share": "Openbare deellink", "purchase_account_info": "Supporter", "purchase_activated_subtitle": "Bedankt voor het ondersteunen van Immich en open-source software", "purchase_activated_time": "Geactiveerd op {date, date}", @@ -1022,6 +1079,10 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "De productcode van de server wordt beheerd door de beheerder", "range": "", + "rating": "Ster waardering", + "rating_clear": "Waardering verwijderen", + "rating_count": "{count, plural, one {# ster} other {# sterren}}", + "rating_description": "De EXIF-waardering weergeven in het infopaneel", "raw": "", "reaction_options": "Reactie opties", "read_changelog": "Lees wijzigingen", @@ -1033,11 +1094,13 @@ "recent_searches": "Recente zoekopdrachten", "refresh": "Vernieuwen", "refresh_encoded_videos": "Vernieuw gecodeerde video's", + "refresh_faces": "Vernieuw gezichten", "refresh_metadata": "Vernieuw metadata", "refresh_thumbnails": "Vernieuw thumbnails", "refreshed": "Verniewd", - "refreshes_every_file": "Vernieuwt elk bestand", + "refreshes_every_file": "Vernieuwt alle bestaande en nieuwe bestanden", "refreshing_encoded_video": "Gecodeerde video aan het vernieuwen", + "refreshing_faces": "Gezichten aan het vernieuwen", "refreshing_metadata": "Metadata aan het vernieuwen", "regenerating_thumbnails": "Thumbnails opnieuw aan het genereren", "remove": "Verwijderen", @@ -1045,15 +1108,16 @@ "remove_assets_shared_link_confirmation": "Weet je zeker dat je {count, plural, one {# asset} other {# assets}} uit deze gedeelde link wilt verwijderen?", "remove_assets_title": "Assets verwijderen?", "remove_custom_date_range": "Aangepast datumbereik verwijderen", + "remove_deleted_assets": "Verwijder offline bestanden", "remove_from_album": "Verwijder uit album", "remove_from_favorites": "Verwijderen uit favorieten", "remove_from_shared_link": "Verwijderen uit gedeelde link", - "remove_offline_files": "Verwijder offline bestanden", "remove_user": "Gebruiker verwijderen", "removed_api_key": "API sleutel verwijderd: {name}", "removed_from_archive": "Verwijderd uit archief", "removed_from_favorites": "Verwijderd uit favorieten", "removed_from_favorites_count": "{count, plural, other {# verwijderd}} uit favorieten", + "removed_tagged_assets": "Tag verwijderd van {count, plural, one {# asset} other {# assets}}", "rename": "Hernoemen", "repair": "Repareren", "repair_no_results_message": "Niet bijgehouden en ontbrekende bestanden zullen hier verschijnen", @@ -1085,6 +1149,7 @@ "say_something": "Zeg iets", "scan_all_libraries": "Scan alle bibliotheken", "scan_all_library_files": "Herscan alle bibliotheekbestanden", + "scan_library": "Scannen", "scan_new_library_files": "Scan nieuwe bibliotheekbestanden", "scan_settings": "Scaninstellingen", "scanning_for_album": "Scannen voor album...", @@ -1100,9 +1165,12 @@ "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_settings": "Zoek instellingen", "search_state": "Zoek staat...", + "search_tags": "Tags zoeken...", "search_timezone": "Zoek tijdzone...", "search_type": "Type zoekopdracht", "search_your_photos": "Foto's doorzoeken", @@ -1144,6 +1212,7 @@ "shared_by_user": "Gedeeld door {user}", "shared_by_you": "Gedeeld door jou", "shared_from_partner": "Foto's van {partner}", + "shared_link_options": "Opties voor gedeelde links", "shared_links": "Gedeelde links", "shared_photos_and_videos_count": "{assetCount, plural, other {# gedeelde foto's & video's.}}", "shared_with_partner": "Gedeeld met {partner}", @@ -1152,6 +1221,7 @@ "sharing_sidebar_description": "Toon een link naar Delen in de zijbalk", "shift_to_permanent_delete": "druk op ⇧ om assets permanent te verwijderen", "show_album_options": "Toon albumopties", + "show_albums": "Toon albums", "show_all_people": "Toon alle mensen", "show_and_hide_people": "Toon & verberg mensen", "show_file_location": "Toon bestandslocatie", @@ -1166,13 +1236,18 @@ "show_person_options": "Toon persoonopties", "show_progress_bar": "Toon voortgangsbalk", "show_search_options": "Zoekopties weergeven", + "show_slideshow_transition": "Diavoorstellingsovergang tonen", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Toon een supporterbadge", "shuffle": "Willekeurig", + "sidebar": "Zijbalk", + "sidebar_display_description": "Toon een link naar deze pagina in de zijbalk", "sign_out": "Uitloggen", "sign_up": "Registreren", "size": "Grootte", "skip_to_content": "Doorgaan naar inhoud", + "skip_to_folders": "Doorgaan naar mappen", + "skip_to_tags": "Doorgaan naar tags", "slideshow": "Diavoorstelling", "slideshow_settings": "Diavoorstelling instellingen", "sort_albums_by": "Sorteer albums op...", @@ -1184,6 +1259,8 @@ "sort_title": "Titel", "source": "Bron", "stack": "Stapel", + "stack_duplicates": "Stapel duplicaten", + "stack_select_one_photo": "Selecteer één primaire foto voor de stapel", "stack_selected_photos": "Geselecteerde foto's stapelen", "stacked_assets_count": "{count, plural, one {# asset} other {# assets}} gestapeld", "stacktrace": "Stacktrace", @@ -1201,22 +1278,36 @@ "submit": "Verzenden", "suggestions": "Suggesties", "sunrise_on_the_beach": "Zonsopkomst op het strand", + "support": "Ondersteuning", + "support_and_feedback": "Ondersteuning & feedback", + "support_third_party_description": "Je Immich installatie is door een derde partij samengesteld. Problemen die je ervaart, kunnen door dat pakket veroorzaakt zijn. Meld problemen in eerste instantie bij hen via de onderstaande links.", "swap_merge_direction": "Wissel richting voor samenvoegen om", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Assets taggen", + "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_updated": "Tag bijgewerkt: {tag}", + "tagged_assets": "{count, plural, one {# asset} other {# assets}} getagd", + "tags": "Tags", "template": "Template", "theme": "Thema", "theme_selection": "Thema selectie", "theme_selection_description": "Stel het thema automatisch in op licht of donker op basis van de systeemvoorkeuren van je browser", "they_will_be_merged_together": "Zij zullen worden samengevoegd", + "third_party_resources": "Bronnen van derden", "time_based_memories": "Tijdgebaseerde herinneringen", "timezone": "Tijdzone", "to_archive": "Archiveren", "to_change_password": "Wijzig wachtwoord", "to_favorite": "Toevoegen aan favorieten", "to_login": "Inloggen", + "to_parent": "Ga naar hoofdmap", + "to_root": "Naar hoofdmap", "to_trash": "Prullenbak", "toggle_settings": "Zichtbaarheid instellingen wisselen", - "toggle_theme": "Thema wisselen", + "toggle_theme": "Donker thema toepassen", "toggle_visibility": "Zichtbaarheid wisselen", "total_usage": "Totaal gebruik", "trash": "Prullenbak", @@ -1235,9 +1326,11 @@ "unknown_album": "Onbekend album", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", + "unlink_motion_video": "Maak bewegende video los", "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", + "unnamed_album_delete_confirmation": "Weet je zeker dat je dit album wilt verwijderen?", "unnamed_share": "Naamloze deellink", "unsaved_change": "Niet-opgeslagen wijziging", "unselect_all": "Alles deselecteren", @@ -1277,6 +1370,8 @@ "version": "Versie", "version_announcement_closing": "Je vriend, Alex", "version_announcement_message": "Hallo vriend, er is een nieuwe versie van de applicatie beschikbaar. Neem de tijd om de release notes te bekijken en zorg ervoor dat je docker-compose.yml en .env up-to-date zijn om misconfiguraties te voorkomen, vooral als je WatchTower of een andere automatische update-mechanisme gebruikt.", + "version_history": "Versiegeschiedenis", + "version_history_item": "{version} geïnstalleerd op {date}", "video": "Video", "video_hover_setting": "Speel video thumbnail af bij hoveren", "video_hover_setting_description": "Speel video thumbnail af wanneer de muis over het item beweegt. Zelfs wanneer uitgeschakeld, kan het afspelen worden gestart door de muis over het afspeelpictogram te bewegen.", @@ -1286,6 +1381,7 @@ "view_album": "Bekijk album", "view_all": "Bekijk alle", "view_all_users": "Bekijk alle gebruikers", + "view_in_timeline": "Bekijk in tijdlijn", "view_links": "Links bekijken", "view_next_asset": "Bekijk volgende asset", "view_previous_asset": "Bekijk vorige asset", diff --git a/web/src/lib/i18n/pl.json b/i18n/pl.json similarity index 88% rename from web/src/lib/i18n/pl.json rename to i18n/pl.json index 6b2fe85a9b..26b89008a7 100644 --- a/web/src/lib/i18n/pl.json +++ b/i18n/pl.json @@ -1,10 +1,10 @@ { "about": "O aplikacji", "account": "Konto", - "account_settings": "Ustawienia Konta", + "account_settings": "Ustawienia konta", "acknowledge": "Rozumiem", "action": "Akcja", - "actions": "Akcje", + "actions": "Akcje/i", "active": "Aktywne", "activity": "Aktywność", "activity_changed": "Aktywność jest {enabled, select, true {włączona} other {wyłączona}}", @@ -15,7 +15,7 @@ "add_a_title": "Dodaj tytuł", "add_exclusion_pattern": "Dodaj wzór wykluczający", "add_import_path": "Dodaj ścieżkę importu", - "add_location": "Dodaj lokacje", + "add_location": "Dodaj lokalizację", "add_more_users": "Dodaj więcej użytkowników", "add_partner": "Dodaj partnera", "add_path": "Dodaj ścieżkę", @@ -25,9 +25,10 @@ "add_to_shared_album": "Dodaj do udostępnionego albumu", "added_to_archive": "Dodano do archiwum", "added_to_favorites": "Dodano do ulubionych", - "added_to_favorites_count": "Dodano {count} do ulubionych", + "added_to_favorites_count": "Dodano {count, number} do ulubionych", "admin": { "add_exclusion_pattern_description": "Dodaj wzorce wykluczające. Wspierane są specjalne sekwencje (glob) *, ** oraz ?. Aby ignorować całą zawartość wszystkich folderów nazwanych \"Raw\", użyj \"**/Raw/**\". Aby ignorować wszystkie pliki kończące się na \".tif\", użyj \"**/*.tif\". Aby ignorować ścieżkę absolutną, użyj \"/ścieżka/do/ignorowania/**\".", + "asset_offline_description": "Ten zewnętrzny zasób biblioteki nie jest już dostępny na dysku i został przeniesiony do kosza. Jeśli plik został przeniesiony w obrębie biblioteki, sprawdź swoją oś czasu pod kątem nowego odpowiadającego zasobu. Aby przywrócić ten zasób, upewnij się, że ścieżka pliku poniżej jest dostępna dla Immich i przeskanuj bibliotekę.", "authentication_settings": "Ustawienia Uwierzytelnienia", "authentication_settings_description": "Zarządzaj hasłem, OAuth i innymi ustawienia uwierzytelnienia", "authentication_settings_disable_all": "Czy jesteś pewny, że chcesz wyłączyć wszystkie metody logowania? Logowanie będzie całkowicie wyłączone.", @@ -41,6 +42,7 @@ "confirm_email_below": "Aby potwierdzić, wpisz \"{email}\" poniżej", "confirm_reprocess_all_faces": "Czy na pewno chcesz ponownie przetworzyć wszystkie twarze? Spowoduje to utratę nazwanych osób.", "confirm_user_password_reset": "Czy na pewno chcesz zresetować hasło użytkownika {user}?", + "create_job": "Utwórz zadanie", "crontab_guru": "Crontab Guru", "disable_login": "Wyłącz logowanie", "disabled": "Wyłączone", @@ -49,27 +51,37 @@ "external_library_created_at": "Biblioteka zewnętrzna (stworzona dnia {date})", "external_library_management": "Zarządzanie Bibliotekami Zewnętrznymi", "face_detection": "Wykrywanie twarzy", - "face_detection_description": "Wykrywanie twarzy w zasobach używając uczenia maszynowego. Twarze w filmach wykryte zostaną tylko jeżeli są widoczne w miniaturze. \"Wszystkie\" ponownie przetwarza wszystkie zasoby. \"Brakujące\" dodaje do kolejki tylko zasoby, które nie zostały jeszcze przetworzone. Wykryte twarze zostaną dodane do kolejki Rozpoznawania Twarzy, aby związać je z istniejącą osobą albo stworzyć nową osobę.", + "face_detection_description": "Wykrywanie twarzy w zasobach używając uczenia maszynowego. Twarze w filmach wykryte zostaną tylko jeżeli są widoczne w miniaturze. \"Wszystkie\" ponownie przetwarza wszystkie zasoby. \"Reset\" dodatkowo usuwa wszystkie bieżące dane twarzy. \"Brakujące\" dodaje do kolejki tylko zasoby, które nie zostały jeszcze przetworzone. Wykryte twarze zostaną dodane do kolejki Rozpoznawania Twarzy, aby związać je z istniejącą osobą albo stworzyć nową osobę.", "facial_recognition_job_description": "Grupuj wykryte twarze. Ten krok uruchamiany jest po zakończeniu wykrywania twarzy. „Wszystkie” – ponownie kategoryzuje wszystkie twarze. „Brakujące” – kategoryzuje twarze, do których nie przypisano osoby.", "failed_job_command": "Polecenie {command} nie powiodło się dla zadania: {job}", "force_delete_user_warning": "UWAGA: Użytkownik i wszystkie zasoby użytkownika zostaną natychmiast trwale usunięte. Nie można tego cofnąć, a plików nie będzie można przywrócić.", "forcing_refresh_library_files": "Wymuś skanowanie wszystkich pliki w bibliotece", + "image_format": "Format", "image_format_description": "Użycie formatu WebP skutkuje utworzeniem plików o rozmiarze mniejszym niż w przypadku JPEG ale jego kodowanie trwa dłużej.", "image_prefer_embedded_preview": "Preferuj podgląd wbudowany", "image_prefer_embedded_preview_setting_description": "Jeśli to możliwe, używaj osadzonych podglądów w zdjęciach RAW jako danych wejściowych do przetwarzania obrazu. Może to zapewnić dokładniejsze kolory w przypadku niektórych obrazów, ale jakość podglądu zależy od aparatu, a obraz może zawierać więcej artefaktów kompresji.", - "image_prefer_wide_gamut": "Preferuj szeroką przestrzeń barw", + "image_prefer_wide_gamut": "Preferuj szeroką paletę barw", "image_prefer_wide_gamut_setting_description": "Do wyświetlania miniatur użyj wyświetlacza P3. Dzięki temu lepiej zachowuje się intensywność obrazów o dużej ilości kolorów, ale obrazy mogą wyglądać inaczej na starych urządzeniach ze starą wersją przeglądarki. Obrazy sRGB są zachowywane jako sRGB, aby uniknąć przesunięć kolorów.", + "image_preview_description": "Obraz średniej wielkości z wyciętymi metadanymi, używany podczas przeglądania pojedynczego zasobu i do uczenia maszynowego", "image_preview_format": "Format podglądu", + "image_preview_quality_description": "Jakość podglądu od 1 do 100. Wyższa jest lepsza, ale powoduje większe pliki i może zmniejszyć responsywność aplikacji. Ustawienie niskiej wartości może wpłynąć na jakość uczenia maszynowego.", "image_preview_resolution": "Rozdzielczość podglądu", "image_preview_resolution_description": "Używane podczas przeglądania pojedynczego zdjęcia i do uczenia maszynowego. Wyższe rozdzielczości pozwalają zachować więcej szczegółów, ale kodowanie zajmuje więcej czasu, powoduje to też większe rozmiary plików i może zmniejszyć czas reakcji aplikacji.", + "image_preview_title": "Ustawienia podglądu", "image_quality": "Jakość", "image_quality_description": "Jakość obrazu od 1 do 100. Wyższe wartości pozwalają uzyskać lepszą jakość ale skutkują większym rozmiarem pliku. Ta opcja wpływa na Podgląd i Miniaturki.", + "image_resolution": "Rozdzielczość", + "image_resolution_description": "Wyższe rozdzielczości pozwalają zachować więcej szczegółów, ale wymagają dłuższego kodowania, mają większy rozmiar pliku i mogą spowalniać reakcję aplikacji.", "image_settings": "Ustawienia Obrazu", "image_settings_description": "Zarządzaj jakością i rozdzielczością generowanych obrazów", + "image_thumbnail_description": "Mała miniatura z wyciętymi metadanymi, używana podczas przeglądania grup zdjęć, takich jak główna oś czasu", "image_thumbnail_format": "Format miniatury", + "image_thumbnail_quality_description": "Jakość miniatur od 1 do 100. Im wyższa, tym lepsza, ale powoduje to większy rozmiar plików i może spowolnić reakcję aplikacji.", "image_thumbnail_resolution": "Rozdzielczość miniatury", "image_thumbnail_resolution_description": "Używane podczas przeglądania grup zdjęć (głównej osi czasu, widoku albumu itp.). Wyższe rozdzielczości pozwalają zachować więcej szczegółów, ale wyświetlenie ich zajmuje więcej czasu, powoduje też zwiększenie rozmiaru plików i może zmniejszyć czas reakcji aplikacji.", + "image_thumbnail_title": "Ustawienia miniatur", "job_concurrency": "{job} współbieżność", + "job_created": "Zadanie utworzone", "job_not_concurrency_safe": "To zadanie nie może zostać wykonane w wielu wątkach.", "job_settings": "Ustawienia Zadań", "job_settings_description": "Zarządzaj współbieżnością zadań", @@ -129,6 +141,7 @@ "map_enable_description": "Włącz funkcję mapy", "map_gps_settings": "Mapa i ustawienia lokalizacji", "map_gps_settings_description": "Zarządzaj mapą oraz ustawieniami odwróconego geokodowania", + "map_implications": "Funkcja mapy opiera się na zewnętrznej usłudze kafelków (tiles.immich.cloud)", "map_light_style": "Styl jasny", "map_manage_reverse_geocoding_settings": "Zarządzaj Ustawieniem Odwrotne Geokodowanie", "map_reverse_geocoding": "Odwrotne Geokodowanie", @@ -138,7 +151,11 @@ "map_settings_description": "Zarządzaj ustawieniami mapy", "map_style_description": "URL do pliku style.json z motywem mapy", "metadata_extraction_job": "Wyodrębnij metadane", - "metadata_extraction_job_description": "Wyodrębnij informacje o metadanych z każdego zasobu, takie jak GPS i rozdzielczość", + "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", + "metadata_faces_import_setting_description": "Zaimportuj twarze z danych EXIF obrazu i plików towarzyszących", + "metadata_settings": "Ustawienia Metadanych", + "metadata_settings_description": "Zarządzaj ustawieniami metadanych", "migration_job": "Migracja", "migration_job_description": "Przenieś miniatury zasobów i twarzy do najnowszej struktury folderów", "no_paths_added": "Nie dodano ścieżki", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "UWAŻAJ: Nie można tego później zmienić!", "note_unlimited_quota": "Wpisz by wyłączyć limit", "notification_email_from_address": "Z adresu", - "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", + "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", "notification_email_host_description": "Host serwera e-mail (np. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoruj niepoprawny certyfikat", "notification_email_ignore_certificate_errors_description": "Ignoruj błąd walidacji certyfikatu TLS (nie zalecane)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "Adres URL wydawcy", "oauth_mobile_redirect_uri": "Mobilny adres zwrotny", "oauth_mobile_redirect_uri_override": "Zapasowy URI przekierowania mobilnego", - "oauth_mobile_redirect_uri_override_description": "Włącz, gdy „app.immich:/” jest nieprawidłowym identyfikatorem URI przekierowania.", + "oauth_mobile_redirect_uri_override_description": "Włącz, gdy dostawca OAuth nie pozwala na mobilne identyfikatory URI typu '{callback}'", "oauth_profile_signing_algorithm": "Algorytm logowania do profilu", "oauth_profile_signing_algorithm_description": "Algorytm używany podczas logowania do profilu użytkownika.", "oauth_scope": "Zakres", @@ -193,19 +210,22 @@ "password_settings": "Logowanie Hasłem", "password_settings_description": "Zarządzaj ustawieniami logowania hasłem", "paths_validated_successfully": "Wszystkie ścieżki zostały pomyślnie zweryfikowane", + "person_cleanup_job": "Porządkowanie osób", "quota_size_gib": "Wielkość Magazynu (GiB)", "refreshing_all_libraries": "Wszystkie biblioteki zostaną odświeżone", "registration": "Rejestracja Administratora", "registration_description": "Jesteś pierwszym użytkownikiem aplikacji, więc twoje konto jest administratorem. Możesz zarządzać platformą, w tym dodawać nowych użytkowników.", - "removing_offline_files": "Niedostępne pliki zostaną usunięte", + "removing_deleted_files": "Niedostępne pliki zostaną usunięte", "repair_all": "Napraw Wszystko", "repair_matched_items": "Powiązano {count, plural, one {# element} few {# elementy} other {# elementów}}", "repaired_items": "Naprawiono {count, plural, one {# element} few {# elementy} other {# elementów}}", "require_password_change_on_login": "Wymagaj zmiany hasła po pierwszym zalogowaniu", "reset_settings_to_default": "Przywróć ustawienia fabryczne", "reset_settings_to_recent_saved": "Przywróć ustawienia do ostatnio zapisanych", + "scanning_library": "Skanowanie biblioteki", "scanning_library_for_changed_files": "Przeszukaj bibliotekę w poszukiwaniu zmian w plikach", "scanning_library_for_new_files": "Przeszukaj bibliotekę w poszukiwaniu nowych plików", + "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)://", @@ -221,7 +241,7 @@ "storage_template_date_time_sample": "Przykładowy czas {date}", "storage_template_enable_description": "Włącz silnik szablonów magazynu", "storage_template_hash_verification_enabled": "Weryfikacja hashu włączona", - "storage_template_hash_verification_enabled_description": "Włącza weryfikację hasha. Nie wyłączaj tej opcji, jeśli nie jesteś pewien konsekwencji", + "storage_template_hash_verification_enabled_description": "Włącza weryfikację sumy kontrolnej. Nie wyłączaj tej opcji, jeśli nie jesteś pewien konsekwencji", "storage_template_migration": "Migracja szablonu magazynu", "storage_template_migration_description": "Zastosuj aktualny szablon {template} do wcześniej przesłanych zasobów", "storage_template_migration_info": "Zmiany w szablonie zostaną zastosowane tylko do nowych zasobów. Aby wstecznie zastosować szablon do wcześniej przesłanych zasobów, uruchom zadanie {job}.", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Zarządzaj strukturą folderów i nazwą pliku przesyłanego zasobu", "storage_template_user_label": "{label} to jest etykieta przechowywania użytkownika", "system_settings": "Ustawienia Systemowe", + "tag_cleanup_job": "Porządkowanie etykiet", "theme_custom_css_settings": "Własny CSS", "theme_custom_css_settings_description": "Właśny CSS pozwala na zmianę wyglądu aplikacji Immich.", "theme_settings": "Ustawienia Motywu", @@ -250,7 +271,7 @@ "transcoding_accepted_audio_codecs": "Akceptowane kodeki audio", "transcoding_accepted_audio_codecs_description": "Wybierz, które kodeki audio nie muszą być transkodowane. Używane tylko w przypadku niektórych zasad transkodowania.", "transcoding_accepted_containers": "Akceptowalne kontenery", - "transcoding_accepted_containers_description": "Wybierz które formaty kontenera nie muszą zostać przerobione na MP4. Użyte tylko w wybranych zasadach transkodowania", + "transcoding_accepted_containers_description": "Wybierz które formaty kontenera nie muszą zostać przerobione na MP4. Użyte tylko w wybranych zasadach transkodowania.", "transcoding_accepted_video_codecs": "Akceptowane kodeki wideo", "transcoding_accepted_video_codecs_description": "Wybierz, które kodeki wideo nie muszą być transkodowane. Używane tylko w przypadku niektórych zasad transkodowania.", "transcoding_advanced_options_description": "Opcje, których większość użytkowników nie powinna zmieniać", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Przyspieszenie Sprzętowe", "transcoding_hardware_acceleration_description": "Eksperymentalny; znacznie szybszy, ale będzie miał niższą jakość przy tej samej szybkości transmisji", "transcoding_hardware_decoding": "Dekodowanie sprzętowe", - "transcoding_hardware_decoding_setting_description": "Dotyczy tylko NVENC, QSV i RKMPP. Umożliwia całkowite przyspieszenie sprzętowe zamiast tylko przyspieszania kodowania. Może nie działać we wszystkich filmach.", + "transcoding_hardware_decoding_setting_description": "Umożliwia całkowite przyspieszenie sprzętowe zamiast tylko przyspieszania kodowania. Może nie działać we wszystkich filmach.", "transcoding_hevc_codec": "Kodek HEVC", "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.", @@ -307,6 +328,7 @@ "trash_settings_description": "Zarządzaj ustawieniami kosza", "untracked_files": "Nieśledzone pliki", "untracked_files_description": "Pliki te nie są śledzone przez aplikację. Mogą być wynikiem nieudanych przeniesień, przerwanego przesyłania lub pozostawienia z powodu błędu", + "user_cleanup_job": "Porządkowanie użytkownika", "user_delete_delay": "Konto {user} oraz jego zasoby zostaną zaplanowane do trwałego usunięcia za {delay, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}.", "user_delete_delay_settings": "Usuń opóźnienie", "user_delete_delay_settings_description": "Liczba dni po usunięciu, po której następuje trwałe usunięcie konta użytkownika i zasobów. Zadanie usuwania użytkowników jest uruchamiane o północy w celu sprawdzenia, czy użytkownicy są gotowi do usunięcia. Zmiany tego ustawienia zostaną sprawdzone przy następnym wykonaniu.", @@ -320,7 +342,8 @@ "user_settings": "Ustawienia Użytkownika", "user_settings_description": "Zarządzaj ustawieniami użytkownika", "user_successfully_removed": "Użytkownik {email} został usunięty pomyślnie.", - "version_check_enabled_description": "Włącz cykliczne sprawdzanie nowych wersji na GitHubie", + "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", "video_conversion_job": "Transkodowanie wideo", @@ -336,7 +359,8 @@ "album_added": "Album udostępniony", "album_added_notification_setting_description": "Otrzymaj powiadomienie email, gdy zostanie Ci udostępniony album", "album_cover_updated": "Okładka albumu została zaktualizowana", - "album_delete_confirmation": "Na pewno chcesz usunąć album {album}?\nJeśli został udostępniony, inni użytkownicy nie będą w stanie go obejrzeć.", + "album_delete_confirmation": "Czy na pewno chcesz usunąć album {album}?", + "album_delete_confirmation_description": "Jeżeli album jest udostępniany, inny stracą do niego dostęp.", "album_info_updated": "Szczegóły albumu zostały zaktualizowane", "album_leave": "Opuścić album?", "album_leave_confirmation": "Na pewno chcesz opuścić {album}?", @@ -360,6 +384,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", + "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.", "api_key_empty": "Twój Klucz API nie powinien być pusty", @@ -368,8 +393,8 @@ "appears_in": "W albumach", "archive": "Archiwum", "archive_or_unarchive_photo": "Dodaj lub usuń zasób z archiwum", - "archive_size": "Maksymalny Rozmiar Archiwum", - "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tą wartość w GiB", + "archive_size": "Rozmiar archiwum", + "archive_size_description": "Podziel pobierane pliki na więcej niż jedno archiwum, jeżeli rozmiar archiwum przekroczy tę wartość w GiB", "archived": "Zarchiwizowano", "archived_count": "{count, plural, other {Zarchiwizowano #}}", "are_these_the_same_person": "Czy to jedna i ta sama osoba?", @@ -381,8 +406,9 @@ "asset_has_unassigned_faces": "Zasób ma nieprzypisane twarze", "asset_hashing": "Hashowanie...", "asset_offline": "Zasób niedostępny", - "asset_offline_description": "Ten zasób jest offline. Immich nie może uzyskać dostępu do jego lokalizacji pliku. Upewnij się, że zasób jest dostępny, a następnie ponownie zeskanuj bibliotekę.", + "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...", "assets": "Zasoby", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "Przeniesiono {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}} do kosza", "assets_permanently_deleted_count": "Trwale usunięto {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "assets_removed_count": "Usunięto {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", - "assets_restore_confirmation": "Na pewno chcesz przywrócić wszystkie zasoby z kosza? Nie da się tego cofnąć!", + "assets_restore_confirmation": "Na pewno chcesz przywrócić wszystkie zasoby z kosza? Nie da się tego cofnąć! Należy pamiętać, że w ten sposób nie można przywrócić zasobów offline.", "assets_restored_count": "Przywrócono {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "assets_trashed_count": "Wrzucono do kosza {count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "assets_were_part_of_album_count": "{count, plural, one {Zasób był} few {Zasoby były} many {Zasobów było} other {Zasobów było}} już częścią albumu", @@ -405,7 +431,8 @@ "birthdate_saved": "Data urodzenia zapisana pomyślnie", "birthdate_set_description": "Data urodzenia jest używana do obliczenia wieku danej osoby podczas wykonania zdjęcia.", "blurred_background": "Rozmyte tło", - "build": "Build", + "bugs_and_feature_requests": "Błędy i prośby o funkcje", + "build": "Kompilacja", "build_image": "Obraz Buildu", "bulk_delete_duplicates_confirmation": "Czy na pewno chcesz trwale usunąć {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? Zostanie zachowany największy zasób z każdej grupy, a wszystkie pozostałe duplikaty zostaną trwale usunięte. Nie można cofnąć tej operacji!", "bulk_keep_duplicates_confirmation": "Czy na pewno chcesz zachować {count, plural, one {# zduplikowany zasób} few {# zduplikowane zasoby} many {# zduplikowanych zasobów} other {# zduplikowanych zasobów}}? To spowoduje rozwiązanie wszystkich grup duplikatów bez usuwania czegokolwiek.", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Usuń ostatnio wyszukiwane", "clear_message": "Zamknij wiadomość", "clear_value": "Wyczyść wartość", + "clockwise": "Zgodnie z ruchem wskazówek zegara", "close": "Zamknij", "collapse": "Zwiń", "collapse_all": "Zwiń wszystko", + "color": "Kolor", "color_theme": "Motyw kolorów", "comment_deleted": "Usunięto komentarz", "comment_options": "Opcje komentarza", @@ -477,6 +506,8 @@ "create_new_person": "Stwórz nową osobę", "create_new_person_hint": "Przypisz wybrane zasoby do nowej osoby", "create_new_user": "Stwórz nowego użytkownika", + "create_tag": "Stwórz etykietę", + "create_tag_description": "Stwórz nową etykietę. Dla etykiet zagnieżdżonych, wprowadź pełną ścieżkę etykiety zawierającą ukośniki.", "create_user": "Stwórz użytkownika", "created": "Utworzono", "current_device": "Obecne urządzenie", @@ -500,13 +531,17 @@ "delete_library": "Usuń bibliotekę", "delete_link": "Usuń link", "delete_shared_link": "Usuń udostępniony link", + "delete_tag": "Usuń etykietę", + "delete_tag_confirmation_prompt": "Czy na pewno chcesz usunąć etykietę {tagName}?", "delete_user": "Usuń użytkownika", "deleted_shared_link": "Pomyślnie usunięto udostępniony link", + "deletes_missing_assets": "Usuwa brakujące zasoby z dysku", "description": "Opis", "details": "Szczegóły", "direction": "Kierunek", "disabled": "Wyłączone", "disallow_edits": "Nie pozwalaj edytować", + "discord": "Discord", "discover": "Odkryj", "dismiss_all_errors": "Odrzuć wszystkie błędy", "dismiss_error": "Odrzuć błąd", @@ -515,8 +550,11 @@ "display_original_photos": "Wyświetlaj oryginalne zdjęcia", "display_original_photos_setting_description": "Wyświetlając zdjęcia i filmy, preferuj oryginalny plik zamiast miniatur jeżeli jest działa on w przeglądarce. Może to skutkować wolniejszym ładowaniem zdjęć i filmów.", "do_not_show_again": "Nie pokazuj więcej tej wiadomości", + "documentation": "Dokumentacja", "done": "Gotowe", "download": "Pobierz", + "download_include_embedded_motion_videos": "Osadzone filmy", + "download_include_embedded_motion_videos_description": "Dołącz filmy osadzone w ruchomych zdjęciach jako oddzielny plik", "download_settings": "Pobieranie", "download_settings_description": "Zarządzaj pobieraniem zasobów", "downloading": "Pobieranie", @@ -546,10 +584,15 @@ "edit_location": "Edytuj lokalizację", "edit_name": "Edytuj imię", "edit_people": "Edytuj osoby", + "edit_tag": "Edytuj etykietę", "edit_title": "Edytuj Tytuł", "edit_user": "Edytuj użytkownika", "edited": "Edytowane", "editor": "Edytor", + "editor_close_without_save_prompt": "Zmiany nie zostaną zapisane", + "editor_close_without_save_title": "Zamknąć edytor?", + "editor_crop_tool_h2_aspect_ratios": "Proporcje obrazu", + "editor_crop_tool_h2_rotation": "Obrót", "email": "E-mail", "empty": "", "empty_album": "Pusty Album", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Nie udało się uzyskać liczby komentarzy", "unable_to_get_shared_link": "Nie udało się uzyskać udostępnionego linku", "unable_to_hide_person": "Ukrycie osoby nie powiodło się", + "unable_to_link_motion_video": "Nie można podłączyć ruchome wideo", "unable_to_link_oauth_account": "Nie można powiązać konta OAuth", "unable_to_load_album": "Ładowanie albumu nie powiodło się", "unable_to_load_asset_activity": "Ładowanie aktywności nie powiodło się", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Usunięcie Klucza API nie powiodło się", "unable_to_remove_assets_from_shared_link": "Nie można usunąć zasobów z udostępnionego linku", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Usunięcie niedostępnych plików nie powiodło się", "unable_to_remove_library": "Usunięcie biblioteki nie powiodło się", - "unable_to_remove_offline_files": "Usunięcie niedostępnych plików nie powiodło się", "unable_to_remove_partner": "Nie można usunąć partnerów", "unable_to_remove_reaction": "Usunięcie reakcji nie powiodło się", "unable_to_remove_user": "", @@ -679,6 +723,7 @@ "unable_to_submit_job": "Nie można przesłać zadania", "unable_to_trash_asset": "Przeniesienie zasobu do kosza nie powiodło się", "unable_to_unlink_account": "Odłączenie konta nie powiodło się", + "unable_to_unlink_motion_video": "Nie można odłączyć ruchomego wideo", "unable_to_update_album_cover": "Nie można zaktualizować okładki albumu", "unable_to_update_album_info": "Nie można zaktualizować informacji o albumie", "unable_to_update_library": "Nie można zaktualizować biblioteki", @@ -692,13 +737,14 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exif": "Exif", + "exif": "Metadane EXIF", "exit_slideshow": "Zamknij Pokaz Slajdów", "expand_all": "Rozwiń wszystko", "expire_after": "Wygasa po", "expired": "Wygasły", "expires_date": "Wygasa {date}", "explore": "Przeglądaj", + "explorer": "Eksplorator", "export": "Eksportuj", "export_as_json": "Eksportuj jako JSON", "extension": "Rozszerzenie", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Pomyślnie zmieniono główne zdjęcie", "featurecollection": "", + "features": "Funkcje", + "features_setting_description": "Zarządzaj funkcjami aplikacji", "file_name": "Nazwa pliku", "file_name_or_extension": "Nazwie lub rozszerzeniu pliku", "filename": "Nazwa pliku", @@ -720,6 +768,8 @@ "filter_people": "Szukaj osoby", "find_them_fast": "Wyszukuj szybciej przypisując nazwę", "fix_incorrect_match": "Napraw nieprawidłowe dopasowanie", + "folders": "Foldery", + "folders_feature_description": "Przeglądanie zdjęć i filmów w widoku folderów", "force_re-scan_library_files": "Wymuś ponowne przeskanowanie wszystkich plików biblioteki", "forward": "Do przodu", "general": "Ogólne", @@ -743,7 +793,16 @@ "host": "Host", "hour": "Godzina", "image": "Zdjęcie", - "image_alt_text_date": "dnia {date}", + "image_alt_text_date": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione dnia {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1} dnia {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1} i {person2} dnia {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1}, {person2} i {person3} dnia {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1}, {person2} i {additionalCount, number} innymi dnia {date}", + "image_alt_text_date_place": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} dnia {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1} dnia {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1} i {person2} dnia {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1}, {person2} i {person3} dnia {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1}, {person2} i {additionalCount, number} innymi dnia {date}", "image_alt_text_people": "{count, plural, =1 {z {person1}} =2 {z {person1} i {person2}} =3 {z {person1}, {person2} i {person3}} other {z {person1}, {person2} i {others, number} innymi}}", "image_alt_text_place": "w {city}, {country}", "image_taken": "{isVideo, select, true {nagrany film} other {zrobione zdjęcie}}", @@ -785,6 +844,7 @@ "library_options": "Opcje biblioteki", "light": "Jasny", "like_deleted": "Polubienie usunięte", + "link_motion_video": "Podłącz ruchome wideo", "link_options": "Opcje linku", "link_to_oauth": "Połącz z OAuth", "linked_oauth_account": "Połączone konto OAuth", @@ -803,6 +863,7 @@ "look": "Wygląd", "loop_videos": "Powtarzaj filmy", "loop_videos_description": "Włącz automatyczne zapętlanie wideo w przeglądarce szczegółów.", + "main_branch_warning": "Używasz wersji deweloperskiej. Rekomendujemy instalację stabilnej wersji aplikacji!", "make": "Marka", "manage_shared_links": "Zarządzaj udostępnionymi linkami", "manage_sharing_with_partners": "Zarządzaj dzieleniem z partnerami", @@ -872,18 +933,21 @@ "notifications": "Powiadomienia", "notifications_setting_description": "Zarządzanie powiadomieniami", "oauth": "OAuth", + "official_immich_resources": "Oficjalne zasoby Immicha", "offline": "Offline", "offline_paths": "Ścieżki offline", "offline_paths_description": "Te wyniki mogą być spowodowane ręcznym usunięciem plików, które nie są częścią zewnętrznej biblioteki.", "ok": "Ok", "oldest_first": "Od najstarszych", "onboarding": "Wdrożenie", + "onboarding_privacy_description": "Śledzenie (opcjonalne) funkcja opiera się na zewnętrznych usługach i może zostać wyłączona w dowolnym momencie w ustawieniach administracyjnych.", "onboarding_theme_description": "Wybierz motyw kolorystyczny dla twojej instancji. Możesz go później zmienić w ustawieniach.", "onboarding_welcome_description": "Przejdźmy do konfiguracji twojej instancji, ustawiając kilka powszechnych opcji.", "onboarding_welcome_user": "Witaj, {user}", "online": "Połączony", "only_favorites": "Tylko ulubione", "only_refreshes_modified_files": "Odświeża tylko zmodyfikowane pliki", + "open_in_map_view": "Otwórz w widoku mapy", "open_in_openstreetmap": "Otwórz w OpenStreetMap", "open_the_search_filters": "Otwórz filtry wyszukiwania", "options": "Opcje", @@ -918,6 +982,7 @@ "pending": "Oczekujące", "people": "Osoby", "people_edits_count": "Edytowano {count, plural, one {# osoba} few {# osoby} many {# osób} other {# osób}}", + "people_feature_description": "Przeglądanie zdjęć i filmów pogrupowanych według osób", "people_sidebar_description": "Pokazuj link do Osób w panelu bocznym", "perform_library_tasks": "", "permanent_deletion_warning": "Ostrzeżenie o trwałym usunięciu", @@ -943,19 +1008,20 @@ "play_or_pause_video": "Odtwórz lub wstrzymaj wideo", "point": "", "port": "Port", - "preset": "Preset", + "preset": "Ustawienie", "preview": "Podgląd", "previous": "Poprzedni", "previous_memory": "Poprzednie wspomnienie", "previous_or_next_photo": "Poprzednie lub następne zdjęcie", "primary": "Główny", + "privacy": "Prywatność", "profile_image_of_user": "Zdjęcie profilowe {user}", "profile_picture_set": "Zdjęcie profilowe ustawione.", "public_album": "Publiczny album", "public_share": "Udostępnienie publiczne", "purchase_account_info": "Wspierający", "purchase_activated_subtitle": "Dziękuję za wspieranie Immich i oprogramowania open-source", - "purchase_activated_time": "Aktywowane dnia {date}", + "purchase_activated_time": "Aktywowane dnia {date, date}", "purchase_activated_title": "Twój klucz został pomyślnie aktywowany", "purchase_button_activate": "Aktywuj", "purchase_button_buy": "Kup", @@ -986,6 +1052,10 @@ "purchase_server_title": "Serwer", "purchase_settings_server_activated": "Klucz produktu serwera jest zarządzany przez administratora", "range": "", + "rating": "Ocena gwiazdkowa", + "rating_clear": "Wyczyść oceną", + "rating_count": "{count, plural, one {# gwiazdka} other {# gwiazdek}}", + "rating_description": "Wyświetl ocenę z EXIF w panelu informacji", "raw": "", "reaction_options": "Opcje reakcji", "read_changelog": "Zobacz Zmiany", @@ -997,11 +1067,13 @@ "recent_searches": "Ostatnie wyszukiwania", "refresh": "Odśwież", "refresh_encoded_videos": "Odśwież enkodowane wideo", + "refresh_faces": "Odśwież twarze", "refresh_metadata": "Odśwież metadane", "refresh_thumbnails": "Odśwież miniatury", "refreshed": "Odświeżone", - "refreshes_every_file": "Odświeża każdy plik", + "refreshes_every_file": "Ponownie odczytuje wszystkie istniejące i nowe pliki", "refreshing_encoded_video": "Odświeżanie enkodowanych wideo", + "refreshing_faces": "Odświeżanie twarzy", "refreshing_metadata": "Odświeżanie metadanych", "regenerating_thumbnails": "Regenerowanie miniatur", "remove": "Usuń", @@ -1009,15 +1081,16 @@ "remove_assets_shared_link_confirmation": "Czy na pewno chcesz usunąć {count, plural, one {# zasób} other {# zasoby}} z tego udostępnionego linku?", "remove_assets_title": "Usunąć zasoby?", "remove_custom_date_range": "Usuń niestandardowy zakres dat", + "remove_deleted_assets": "Usuń Niedostępne Pliki", "remove_from_album": "Usuń z albumu", "remove_from_favorites": "Usuń z ulubionych", "remove_from_shared_link": "Usuń z udostępnionego linku", - "remove_offline_files": "Usuń Niedostępne Pliki", "remove_user": "Usuń użytkownika", "removed_api_key": "Usunięto Klucz API: {name}", "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_tagged_assets": "Usunięto etykietę z {count, plural, one {# zasobu} other {# zasobów}}", "rename": "Zmień nazwę", "repair": "Napraw", "repair_no_results_message": "Tutaj pojawią się nieśledzone i brakujące pliki", @@ -1049,6 +1122,7 @@ "say_something": "Powiedz coś", "scan_all_libraries": "Skanuj wszystkie biblioteki", "scan_all_library_files": "Przeskanuj ponownie wszystkie biblioteki", + "scan_library": "Skanuj", "scan_new_library_files": "Skanuj nowe pliki biblioteki", "scan_settings": "Ustawienia Skanowania", "scanning_for_album": "Skanuję album...", @@ -1064,9 +1138,12 @@ "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_state": "Wyszukaj byt...", + "search_settings": "Ustawienia przeszukiwania", + "search_state": "Wyszukaj stan...", + "search_tags": "Wyszukaj etykiety...", "search_timezone": "Wyszukaj strefę czasową...", "search_type": "Wyszukaj w", "search_your_photos": "Szukaj swoich zdjęć", @@ -1108,6 +1185,7 @@ "shared_by_user": "Udostępnione przez {user}", "shared_by_you": "Udostępnione przez ciebie", "shared_from_partner": "Zdjęcia od {partner}", + "shared_link_options": "Opcje udostępniania linku", "shared_links": "Udostępnione linki", "shared_photos_and_videos_count": "{assetCount, plural, other {# udostępnione zdjęcia i filmy.}}", "shared_with_partner": "Dzielisz się z {partner}", @@ -1116,6 +1194,7 @@ "sharing_sidebar_description": "Wyświetl link do udostępniania na pasku bocznym", "shift_to_permanent_delete": "naciśnij ⇧, aby trwale usunąć zasób", "show_album_options": "Pokaż opcje albumu", + "show_albums": "Pokaż albumy", "show_all_people": "Pokaż wszystkie osoby", "show_and_hide_people": "Pokaż lub ukryj osoby", "show_file_location": "Pokaż ścieżkę pliku", @@ -1130,13 +1209,18 @@ "show_person_options": "Pokaż opcje osoby", "show_progress_bar": "Pokaż pasek postępu", "show_search_options": "Wyświetl opcje wyszukiwania", + "show_slideshow_transition": "Pokaż przejście pokazu slajdów", "show_supporter_badge": "Odznaka wspierającego", "show_supporter_badge_description": "Pokaż odznakę wspierającego", "shuffle": "Losuj", + "sidebar": "Panel boczny", + "sidebar_display_description": "Wyświetl link do widoku w pasku bocznym", "sign_out": "Wyloguj się", "sign_up": "Zarejestruj się", "size": "Rozmiar", "skip_to_content": "Przejdź do treści", + "skip_to_folders": "Przejdź do folderów", + "skip_to_tags": "Przejdź do tagów", "slideshow": "Pokaz slajdów", "slideshow_settings": "Ustawienia pokazu slajdów", "sort_albums_by": "Sortuj albumy według...", @@ -1148,9 +1232,11 @@ "sort_title": "Tytuł", "source": "Źródło", "stack": "Stos", + "stack_duplicates": "Stos duplikatów", + "stack_select_one_photo": "Wybierz jedno główne zdjęcie do stosu", "stack_selected_photos": "Układaj wybrane zdjęcia", "stacked_assets_count": "Ułożone {count, plural, one {# zasób} other{# zasoby}}", - "stacktrace": "Stacktrace", + "stacktrace": "Ślad stosu", "start": "Start", "start_date": "Od dnia", "state": "Stan", @@ -1165,27 +1251,40 @@ "submit": "Zatwierdź", "suggestions": "Sugestie", "sunrise_on_the_beach": "Wschód słońca na plaży", + "support": "Wsparcie", + "support_and_feedback": "Wsparcie i opinie", + "support_third_party_description": "Twoja instalacja immich została spakowana przez trzecią stronę. Problemy, które napotykasz, mogą być spowodowane przez ten pakiet, więc w pierwszej kolejności zgłaszaj problemy u nich, korzystając z poniższych linków.", "swap_merge_direction": "Zmień kierunek złączenia", "sync": "Synchronizuj", + "tag": "Etykieta", + "tag_assets": "Ustaw etykiety zasobów", + "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_updated": "Uaktualniono etykietę: {tag}", + "tagged_assets": "Przypisano etykietę {count, plural, one {# zasobowi} other {# zasobom}}", + "tags": "Etykiety", "template": "Szablon", "theme": "Motyw", "theme_selection": "Wybór motywu", "theme_selection_description": "Automatycznie zmień motyw na jasny lub ciemny zależnie od ustawień przeglądarki", "they_will_be_merged_together": "Zostaną one ze sobą połączone", + "third_party_resources": "Zasoby stron trzecich", "time_based_memories": "Wspomnienia oparte na czasie", "timezone": "Strefa czasowa", "to_archive": "Archiwum", "to_change_password": "Zmień hasło", "to_favorite": "Dodaj do ulubionych", "to_login": "Login", + "to_parent": "Idź do rodzica", "to_trash": "Kosz", "toggle_settings": "Przełącz ustawienia", - "toggle_theme": "Przełącz motyw", + "toggle_theme": "Przełącz ciemny motyw", "toggle_visibility": "Zmień widoczność", "total_usage": "Całkowite wykorzystanie", "trash": "Kosz", "trash_all": "Usuń wszystko", - "trash_count": "Kosz {count}", + "trash_count": "Kosz {count, number}", "trash_delete_asset": "Kosz/Usuń zasób", "trash_no_results_message": "Tu znajdziesz wyrzucone zdjęcia i filmy.", "trashed_items_will_be_permanently_deleted_after": "Wyrzucone zasoby zostaną trwale usunięte po {days, plural, one {jednym dniu} other {{days, number} dniach}}.", @@ -1199,9 +1298,11 @@ "unknown_album": "Nieznany album", "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", "unnamed_album": "Nienazwany album", + "unnamed_album_delete_confirmation": "Czy jesteś pewna/pewien, że chcesz usunąć te album?", "unnamed_share": "Nienazwany udział", "unsaved_change": "Niezapisana zmiana", "unselect_all": "Odznacz wszystko", @@ -1215,7 +1316,7 @@ "upload": "Prześlij", "upload_concurrency": "Współbieżność wysyłania", "upload_errors": "Przesyłanie zakończone z {count, plural, one {# błąd} other {# błędy}}. Odśwież stronę, aby zobaczyć nowe przesłane zasoby.", - "upload_progress": "Pozostałe {remaining} - Przetworzone {processed}/{total}", + "upload_progress": "Pozostałe {remaining, number} - Przetworzone {processed, number}/{total, number}", "upload_skipped_duplicates": "Pominięte {count, plural, one {# zduplikowany zasób} other {# zduplikowane zasoby}}", "upload_status_duplicates": "Duplikaty", "upload_status_errors": "Błędy", @@ -1239,6 +1340,8 @@ "version": "Wersja", "version_announcement_closing": "Twój przyjaciel Aleks", "version_announcement_message": "Witaj przyjacielu, dostępna jest nowa wersja aplikacji. Poświęć trochę czasu na zapoznanie się z informacjami o wydaniu i upewnij się, że pliki docker-compose.yml i .env konfiguracja jest aktualna, aby zapobiec błędnym konfiguracjom, zwłaszcza jeśli używasz WatchTower lub dowolnego mechanizmu, który obsługuje automatyczne aktualizowanie aplikacji.", + "version_history": "Historia wersji", + "version_history_item": "Zainstalowano {version} w {date}", "video": "Wideo", "video_hover_setting": "Odtwórz miniaturę wideo po najechaniu kursorem", "video_hover_setting_description": "Odtwórz miniaturę wideo po najechaniu myszką na element. Nawet jeśli jest wyłączone, odtwarzanie można rozpocząć, najeżdżając kursorem na ikonę odtwarzania.", @@ -1248,6 +1351,7 @@ "view_album": "Wyświetl Album", "view_all": "Pokaż wszystkie", "view_all_users": "Pokaż wszystkich użytkowników", + "view_in_timeline": "Pokaż na osi czasu", "view_links": "Pokaż łącza", "view_next_asset": "Wyświetl następny zasób", "view_previous_asset": "Wyświetl poprzedni zasób", diff --git a/i18n/pt.json b/i18n/pt.json new file mode 100644 index 0000000000..dda003243e --- /dev/null +++ b/i18n/pt.json @@ -0,0 +1,1369 @@ +{ + "about": "Sobre", + "account": "Conta", + "account_settings": "Definições de Conta", + "acknowledge": "Aceitar", + "action": "Ação", + "actions": "Ações", + "active": "Em execução", + "activity": "Atividade", + "activity_changed": "A atividade está {enabled, select, true {ativada} other {desativada}}", + "add": "Adicionar", + "add_a_description": "Adicionar uma descrição", + "add_a_location": "Adicionar localização", + "add_a_name": "Adicionar um nome", + "add_a_title": "Adicionar um título", + "add_exclusion_pattern": "Adicionar um padrão de exclusão", + "add_import_path": "Adicionar um caminho de importação", + "add_location": "Adicionar localização", + "add_more_users": "Adicionar mais utilizadores", + "add_partner": "Adicionar parceiro", + "add_path": "Adicionar caminho", + "add_photos": "Adicionar fotos", + "add_to": "Adicionar a...", + "add_to_album": "Adicionar ao álbum", + "add_to_shared_album": "Adicionar ao álbum partilhado", + "added_to_archive": "Adicionado ao arquivo", + "added_to_favorites": "Adicionado aos favoritos", + "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", + "admin": { + "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os ficheiros em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os ficheiros que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", + "asset_offline_description": "Este ficheiro proveniente de uma biblioteca externa deixou de estar disponível no disco e foi movido para a reciclagem. Se o ficheiro foi movido no interior da biblioteca, procure na linha de tempo pelo novo ficheiro correspondente. Para restaurar este ficheiro, certifique-se que o caminho do ficheiro abaixo pode ser acedido pelo Immich e analise a biblioteca.", + "authentication_settings": "Definições de Autenticação", + "authentication_settings_description": "Gerir palavras-passe, OAuth, e outras definições de autenticação", + "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de início de sessão? O início de sessão será completamente desativado.", + "authentication_settings_reenable": "Para reativar, use um Comando de servidor.", + "background_task_job": "Tarefas em segundo plano", + "check_all": "Selecionar Tudo", + "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} ?", + "confirm_delete_library_assets": "Tem a certeza de que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# ficheiro incluído} other {todos os # ficheiros incluídos}} do Immich e esta ação não pode ser anulada. Os ficheiros permanecerão no disco.", + "confirm_email_below": "Para confirmar, escreva \"{email}\" abaixo", + "confirm_reprocess_all_faces": "Tem a certeza de que deseja reprocessar todos os rostos? Isto também limpará os nomes das pessoas.", + "confirm_user_password_reset": "Tem a certeza de que deseja redefinir a palavra-passe de {user}?", + "create_job": "Criar tarefa", + "crontab_guru": "Guru do Crontab", + "disable_login": "Desativar inicio de sessão", + "disabled": "", + "duplicate_detection_job_description": "Executa a aprendizagem de máquina em ficheiros para detetar imagens semelhantes. Depende da Pesquisa Inteligente", + "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar ficheiros e pastas ao analisar a sua biblioteca. Isto é útil se tiver pastas que contenham ficheiros que não deseja importar, como ficheiros RAW.", + "external_library_created_at": "Biblioteca externa (criada em {date})", + "external_library_management": "Gestão de bibliotecas externas", + "face_detection": "Deteção de Rostos", + "face_detection_description": "Deteta rostos em ficheiros utilizando aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Atualizar\" (re)processa todos os ficheiros, enquanto \"Redefinir\" elimina todos os dados de rostos. \"Em falta\" coloca em fila ficheiros que ainda não foram processados. Os rostos detetados serão colocados em fila para Reconhecimento Facial após a conclusão da Deteção de Rostos, agrupando-os em pessoas novas ou já existentes.", + "facial_recognition_job_description": "Agrupa rostos detetadas em pessoas. Esta etapa é executada após a conclusão da Deteção de Rostos. \"Redefinir\" (re)agrupa todos os rostos. \"Em falta\" coloca em fila rostos que ainda não têm uma pessoa atribuída.", + "failed_job_command": "Comando {command} falhou para a tarefa: {job}", + "force_delete_user_warning": "AVISO: Isto removerá imediatamente o utilizador e todos os ficheiros. Isso não pode ser revertido e os ficheiros não poderão ser recuperados.", + "forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca", + "image_format": "Formato", + "image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.", + "image_prefer_embedded_preview": "Preferir visualização incorporada", + "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", + "image_prefer_wide_gamut": "Prefira ampla gama", + "image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância 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 metadados, utilizada ao visualizar um único ficheiro e pela aprendizagem de máquina", + "image_preview_format": "Formato de visualização", + "image_preview_quality_description": "Qualidade de pré-visualização de 1 a 100. Maior é melhor, mas produz ficheiros maiores e pode reduzir a capacidade de resposta da aplicação. Definir um valor demasiado baixo pode afetar a qualidade da aprendizagem de máquina.", + "image_preview_resolution": "Resolução de visualização", + "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizagem de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "image_preview_title": "Definições de Pré-visualização", + "image_quality": "Qualidade", + "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz ficheiros maiores. Esta definição afeta as imagens de visualização e miniatura.", + "image_resolution": "Resolução", + "image_resolution_description": "Resoluções mais altas podem ajudar a preservar mais detalhes mas demoram mais a codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "image_settings": "Definições de imagem", + "image_settings_description": "Gerir a qualidade e resolução das imagens geradas", + "image_thumbnail_description": "Miniatura de tamanho pequena e sem metadados, utilizada ao visualizar grupos de fotos como, por exemplo, na linha de tempo principal", + "image_thumbnail_format": "Formato de miniatura", + "image_thumbnail_quality_description": "Qualidade das miniaturas de 1 a 100. Maior é melhor, mas produz tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "image_thumbnail_resolution": "Resolução de miniatura", + "image_thumbnail_resolution_description": "Utilizado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "image_thumbnail_title": "Definições de Miniaturas", + "job_concurrency": "{job} em simultâneo", + "job_created": "Tarefa criada", + "job_not_concurrency_safe": "Esta tarefa não pode ser executada em simultâneo.", + "job_settings": "Definições de Tarefas", + "job_settings_description": "Gerir tarefas em simultâneo", + "job_status": "Estado das Tarefas", + "jobs_delayed": "{jobCount, plural, one {# adiado} other {# adiados}}", + "jobs_failed": "{jobCount, plural, one {# falhou} other {# falharam}}", + "library_created": "Criada biblioteca: {library}", + "library_cron_expression": "Expressão Cron", + "library_cron_expression_description": "Defina o intervalo de procura utilizando o formato cron. Para mais informações consulte Guru Crontab", + "library_cron_expression_presets": "Predefinições de expressão Cron", + "library_deleted": "Biblioteca eliminada", + "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo sub-pastas, será analisada por imagens e vídeos.", + "library_scanning": "Análise periódica", + "library_scanning_description": "Configurar a análise periódica da biblioteca", + "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_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", + "logging_enable_description": "Ativar registo", + "logging_level_description": "Quando ativado, qual o nível de log a usar.", + "logging_settings": "Registo", + "machine_learning_clip_model": "Modelo CLIP", + "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Tome nota de que é necessário voltar a executar a tarefa de \"Pesquisa Inteligente\" para todas as imagens depois de alterar o modelo.", + "machine_learning_duplicate_detection": "Deteção de Itens Duplicados", + "machine_learning_duplicate_detection_enabled": "Ativar deteção de itens duplicados", + "machine_learning_duplicate_detection_enabled_description": "Se desativado, ficheiros exatamente idênticos serão desduplicados na mesma.", + "machine_learning_duplicate_detection_setting_description": "Utilizar embeddings CLIP para encontrar itens possivelmente duplicados", + "machine_learning_enabled": "Ativar a aprendizagem de máquina", + "machine_learning_enabled_description": "Se desativado, todos as funcionalidades de ML serão desativados, independentemente das definições abaixo.", + "machine_learning_facial_recognition": "Reconhecimento Facial", + "machine_learning_facial_recognition_description": "Detetar, 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 ordenados por ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Tome conta de que ao alterar um modelo, deve executar novamente a tarefa de \"Deteçã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 secção Pessoas na página Explorar.", + "machine_learning_max_detection_distance": "Distância máxima de deteção", + "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando entre 0,001 e 0,1. Valores mais altos detetarão mais duplicidades, mas poderão resultar em falsos positivos.", + "machine_learning_max_recognition_distance": "Distância máxima de reconhecimento", + "machine_learning_max_recognition_distance_description": "Distância máxima entre dois rostos para serem considerados a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular dois rostos como a mesma pessoa, enquanto valores maiores evitam rotular o mesmo rosto como duas pessoas diferentes. Tenha em conta de que é mais fácil unir duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", + "machine_learning_min_detection_score": "Pontuação mínima de deteção", + "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detetado, de 0 a 1. Valores mais baixos detetam 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 faces reconhecidas para uma pessoa ser criada na lista. Aumentar isto torna o Reconhecimento Facial mais preciso, no entanto aumenta a probabilidade de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Definições de aprendizagem de máquina (Machine Learning)", + "machine_learning_settings_description": "Gerir funcionalidades e definições de aprendizagem de máquina", + "machine_learning_smart_search": "Pesquisa Inteligente", + "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": "URL do servidor de aprendizagem de máquina", + "manage_concurrency": "Gerir simultaneidade", + "manage_log_settings": "Gerir definições de registo", + "map_dark_style": "Tema Escuro", + "map_enable_description": "Ativar funcionalidades de mapa", + "map_gps_settings": "Mapas e Definições de GPS", + "map_gps_settings_description": "Gerir Definições de Mapas e GPS (Geocodificação Reversa)", + "map_implications": "A funcionalidade do mapa necessita um serviço externo (tiles.immich.cloud)", + "map_light_style": "Tema Claro", + "map_manage_reverse_geocoding_settings": "Gerir definições de Geocodificação Reversa", + "map_reverse_geocoding": "Geocodificação Reversa", + "map_reverse_geocoding_enable_description": "Ativar Geocodificação Reversa", + "map_reverse_geocoding_settings": "Definições de Geocodificação Reversa", + "map_settings": "Mapa", + "map_settings_description": "Gerir definições do mapa", + "map_style_description": "URL para um tema de mapa style.json", + "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", + "metadata_faces_import_setting_description": "Importar rostos a partir dos dados EXIF da imagem e ficheiros anexos", + "metadata_settings": "Definições de metadados", + "metadata_settings_description": "Gerir definições de metadados", + "migration_job": "Migração", + "migration_job_description": "Migra miniaturas de ficheiros e rostos para a estrutura de pastas mais recente", + "no_paths_added": "Nenhum caminho adicionado", + "no_pattern_added": "Nenhum padrão adicionado", + "note_apply_storage_label_previous_assets": "Observação: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", + "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", + "note_unlimited_quota": "Observação: insira 0 para quota ilimitada", + "notification_email_from_address": "A partir do endereço", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Servidor de Fotos Immich \"", + "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", + "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", + "notification_email_password_description": "Palavra-passe a ser usada ao autenticar no servidor de e-mail", + "notification_email_port_description": "Porta do servidor de e-mail (por exemplo, 25, 465 ou 587)", + "notification_email_sent_test_email_button": "Enviar e-mail de teste e gravar", + "notification_email_setting_description": "Definições para envio de notificações por e-mail", + "notification_email_test_email": "Enviar e-mail de teste", + "notification_email_test_email_failed": "Falha ao enviar e-mail de teste, verifique os valores", + "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique a sua caixa de entrada.", + "notification_email_username_description": "Nome de utilizador a ser usado ao autenticar com o servidor de e-mail", + "notification_enable_email_notifications": "Ativar notificações por e-mail", + "notification_settings": "Definições de notificações", + "notification_settings_description": "Gerir definições de notificações, incluindo e-mail", + "oauth_auto_launch": "Arranque automático", + "oauth_auto_launch_description": "Iniciar o fluxo de login do OAuth automaticamente ao navegar até a página de inicio de sessão", + "oauth_auto_register": "Registo automático", + "oauth_auto_register_description": "Registar automaticamente novos utilizadores após iniciarem sessão com o OAuth", + "oauth_button_text": "Texto do botão", + "oauth_client_id": "ID do Cliente", + "oauth_client_secret": "Segredo do cliente", + "oauth_enable_description": "Iniciar sessão com o OAuth", + "oauth_issuer_url": "URL do emissor", + "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", + "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", + "oauth_mobile_redirect_uri_override_description": "Ative quando o provedor do OAuth não permite um URI móvel, como '{callback}'", + "oauth_profile_signing_algorithm": "Algoritmo de assinatura de perfis", + "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para assinar o perfil de utilizador.", + "oauth_scope": "Escopo", + "oauth_settings": "OAuth", + "oauth_settings_description": "Gerir definições de inicio de sessão do OAuth", + "oauth_settings_more_details": "Para mais informações sobre esta funcionalidade, veja a documentação.", + "oauth_signing_algorithm": "Algoritmo de assinatura", + "oauth_storage_label_claim": "Reivindicação de Rótulo de Armazenamento", + "oauth_storage_label_claim_description": "Definir automaticamente o Rótulo de Armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_claim": "Reivindicação de quota de armazenamento", + "oauth_storage_quota_claim_description": "Definir automaticamente a quota de armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_default": "Quota de armazenamento padrão (GiB)", + "oauth_storage_quota_default_description": "Quota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para quota ilimitada).", + "offline_paths": "Caminhos Offline", + "offline_paths_description": "Estes resultados podem ser devidos à eliminação manual de ficheiros que não fazem parte de uma biblioteca externa.", + "password_enable_description": "Iniciar sessão com e-mail e palavra-passe", + "password_settings": "Palavra-passe de acesso", + "password_settings_description": "Gerir definições de inicio de sessão e palavra-passe", + "paths_validated_successfully": "Todos os caminhos validados com sucesso", + "person_cleanup_job": "Limpeza de pessoas", + "quota_size_gib": "Tamanho da quota (GiB)", + "refreshing_all_libraries": "A atualizar todas as bibliotecas", + "registration": "Registo de Administrador", + "registration_description": "Como é o primeiro utilizador no sistema, será marcado como administrador, e será responsável pelas tarefas administrativas, sendo que utilizadores adicionais serão criados por si.", + "removing_deleted_files": "Removendo arquivos offline", + "repair_all": "Reparar tudo", + "repair_matched_items": "{count, plural, one {Encontrado # item} other {Encontrados # itens}}", + "repaired_items": "{count, plural, one {Reparado # item} other {Reparados # itens}}", + "require_password_change_on_login": "Exigir que o utilizador altere a palavra-passe no primeiro início de sessão", + "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", + "scanning_library_for_changed_files": "A analisar a biblioteca por ficheiros alterados", + "scanning_library_for_new_files": "A analisar a biblioteca por ficheiros novos", + "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)://", + "server_settings": "Definições do Servidor", + "server_settings_description": "Gerir definições do servidor", + "server_welcome_message": "Mensagem de boas-vindas", + "server_welcome_message_description": "Uma mensagem que é exibida na página de inicio de sessão.", + "sidecar_job": "Metadados secundários", + "sidecar_job_description": "Descobrir ou sincronizar metadados secundários a partir do sistema de ficheiros", + "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", + "smart_search_job_description": "Execute a aprendizagem automática em ficheiros para oferecer apoio à Pesquisa Inteligente", + "storage_template_date_time_description": "O registo de data e hora de criação do ficheiro é usado para fornecer essas informações", + "storage_template_date_time_sample": "Exemplo de tempo {date}", + "storage_template_enable_description": "Ativar mecanismo de modelo de armazenamento", + "storage_template_hash_verification_enabled": "Verificação de hash ativada", + "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_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.", + "storage_template_path_length": "Limite aproximado do tamanho do caminho: {length, number}{limit, number}", + "storage_template_settings": "Modelo de Armazenamento", + "storage_template_settings_description": "Gerir a estrutura de pastas e o nome do ficheiro carregado", + "storage_template_user_label": "{label} é o Rótulo do Armazenamento do utilizador", + "system_settings": "Definições de Sistema", + "tag_cleanup_job": "Limpeza de etiquetas", + "theme_custom_css_settings": "CSS Personalizado", + "theme_custom_css_settings_description": "Folhas de estilo em cascata (CSS) permitem que o design do Immich seja personalizado.", + "theme_settings": "Definições de Tema", + "theme_settings_description": "Gerir a personalização da interface web do Immich", + "these_files_matched_by_checksum": "Estes ficheiros são correspondidos pelas suas somas de verificação", + "thumbnail_generation_job": "Gerar miniaturas", + "thumbnail_generation_job_description": "Gera miniaturas grandes, pequenas e desfocadas para cada ficheiro, bem como miniaturas para cada pessoa", + "transcode_policy_description": "", + "transcoding_acceleration_api": "API de aceleração", + "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta definição é a 'melhor opção': ela voltará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", + "transcoding_acceleration_nvenc": "NVENC (requer GPU NVIDIA)", + "transcoding_acceleration_qsv": "Quick Sync (requer CPU Intel de 7ª geração ou posterior)", + "transcoding_acceleration_rkmpp": "RKMPP (apenas em SOCs Rockchip)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Codecs de áudio aceites", + "transcoding_accepted_audio_codecs_description": "Selecione os codecs de áudio que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_accepted_containers": "Contentores aceites", + "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remisturados para MP4. Usado apenas para algumas políticas de transcodificação.", + "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", + "transcoding_accepted_video_codecs_description": "Selecione quais os codecs de vídeo que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deverá precisar de alterar", + "transcoding_audio_codec": "Codec de áudio", + "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou software antigos.", + "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão num formato aceite", + "transcoding_codecs_learn_more": "Para saber mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", + "transcoding_constant_quality_mode": "Modo de qualidade fixa", + "transcoding_constant_quality_mode_description": "ICQ é melhor que CQP, mas alguns dispositivos de aceleração de hardware não suportam este modo. Definir esta opção dará preferência ao modo especificado ao usar codificação baseada em qualidade. Ignorado pelo NVENC porque não suporta ICQ.", + "transcoding_constant_rate_factor": "Fator de taxa constante (-crf)", + "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz ficheiros maiores.", + "transcoding_disabled_description": "Não transcodificar nenhum vídeo, no entanto pode causar erros de reprodução em alguns clientes", + "transcoding_hardware_acceleration": "Aceleração de hardware", + "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", + "transcoding_hardware_decoding": "Decodificação de hardware", + "transcoding_hardware_decoding_setting_description": "Permite a aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os formatos de arquivo.", + "transcoding_hevc_codec": "Codec HEVC", + "transcoding_max_b_frames": "Máximo de quadros B", + "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", + "transcoding_max_bitrate": "Taxa de bits máxima", + "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos ficheiros mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", + "transcoding_max_keyframe_interval": "Intervalo máximo de quadro-chave", + "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de procura e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", + "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou num formato não aceite", + "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", + "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", + "transcoding_preset_preset": "Predefinição (-preset)", + "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem ficheiros menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápido\".", + "transcoding_reference_frames": "Quadros de referência", + "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao comprimir um determinado quadro. Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. 0 define esse valor automaticamente.", + "transcoding_required_description": "Apenas vídeos que não estejam num formato aceite", + "transcoding_settings": "Definições de transcodificação de vídeo", + "transcoding_settings_description": "Gerir as informações de resolução e codificação dos ficheiros de vídeo", + "transcoding_target_resolution": "Resolução desejada", + "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "transcoding_temporal_aq": "QA temporal", + "transcoding_temporal_aq_description": "Aplica-se apenas ao NVENC. Aumenta a qualidade de cenas com alto detalhe e pouco movimento. Pode não ser compatível com dispositivos mais antigos.", + "transcoding_threads": "Threads", + "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos do CPU. Maximiza a utilização se definido como 0.", + "transcoding_tone_mapping": "Mapeamento de tons", + "transcoding_tone_mapping_description": "Tenta preservar a aparência dos vídeos HDR quando convertidos para SDR. Cada algoritmo faz compensações diferentes em termos de cor, detalhes e brilho. Hable preserva os detalhes, Mobius preserva as cores e Reinhard preserva o brilho.", + "transcoding_tone_mapping_npl": "NPL de mapeamento de tons", + "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho do ecrã. 0 define esse valor automaticamente.", + "transcoding_transcode_policy": "Política de transcodificação", + "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR serão sempre transcodificados (exceto se a transcodificação estiver desativada).", + "transcoding_two_pass_encoding": "Codificação em duas passagens", + "transcoding_two_pass_encoding_setting_description": "Transcodificar em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está ativada (necessário para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desativada.", + "transcoding_video_codec": "Codec de vídeo", + "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz ficheiros muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", + "trash_enabled_description": "Ativar funcionalidade da Reciclagem", + "trash_number_of_days": "Número de dias", + "trash_number_of_days_description": "Número de dias para manter os ficheiros na reciclagem antes de os eliminar permanentemente", + "trash_settings": "Definições da Reciclagem", + "trash_settings_description": "Gerir definições da reciclagem", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_description": "Estes ficheiros não são monitorizados pela aplicação. Podem ser o resultado de transferências mal-sucedidas, carregamentos interrompidos ou deixados para trás devido a um problema", + "user_cleanup_job": "Limpeza de utilizadores", + "user_delete_delay": "A conta e os ficheiros de {user} serão agendados para eliminação permanente dentro de {delay, plural, one {# dia} other {# dias}}.", + "user_delete_delay_settings": "Atraso de eliminação", + "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os ficheiros de um utilizador. A tarefa de eliminação de utilizadores é executada à meia-noite para verificar utilizadores que estão prontos para eliminação. As alterações a esta definição serão avaliadas na próxima execução.", + "user_delete_immediately": "A conta e os ficheiros de {user} serão colocados em fila para eliminação permanente de imediato.", + "user_delete_immediately_checkbox": "Adicionar utilizador e ficheiros à fila para eliminação imediata", + "user_management": "Gestão de utilizadores", + "user_password_has_been_reset": "A palavra-passe do utilizador foi redefinida:", + "user_password_reset_description": "Por favor forneça a palavra-passe temporária ao utilizador e informe-o(a) de que será necessário alterá-la próximo início de sessão.", + "user_restore_description": "A conta de {user} será restaurada.", + "user_restore_scheduled_removal": "Restaurar utilizador - remoção agendada em {date, date, long}", + "user_settings": "Definições do Utilizador", + "user_settings_description": "Gerir definições do utilizador", + "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", + "version_check_enabled_description": "Ativa verificação de novas versões", + "version_check_implications": "A funcionalidade de verificação da versão necessita de comunicação periódica com o github.com", + "version_check_settings": "Verificação de versão", + "version_check_settings_description": "Ativar/desativar a notificação de nova versão", + "video_conversion_job": "Transcodificar vídeos", + "video_conversion_job_description": "Transcodifica vídeos para maior compatibilidade com navegadores e dispositivos" + }, + "admin_email": "E-mail do administrador", + "admin_password": "Palavra-passe do administrador", + "administration": "Administração", + "advanced": "Avançado", + "age_months": "Idade {months, plural, one {# mês} other {# meses}}", + "age_year_months": "Idade 1 ano, {months, plural, one {# mês} other {# meses}}", + "age_years": "{years, plural, one{# ano} other {# anos}}", + "album_added": "Álbum adicionado", + "album_added_notification_setting_description": "Receber uma notificação por e-mail quando for adicionado a um álbum partilhado", + "album_cover_updated": "Capa do álbum atualizada", + "album_delete_confirmation": "Tem a certeza de que quer eliminar o álbum {album}?", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de o poder aceder.", + "album_info_updated": "Informações do álbum atualizadas", + "album_leave": "Sair do álbum?", + "album_leave_confirmation": "Tem a certeza de que quer sair de {album}?", + "album_name": "Nome do álbum", + "album_options": "Opções de álbum", + "album_remove_user": "Remover utilizador?", + "album_remove_user_confirmation": "Tem a certeza de que quer remover {user}?", + "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores com quem o partilhar.", + "album_updated": "Álbum atualizado", + "album_updated_setting_description": "Receber uma notificação por e-mail quando um álbum partilhado tiver novos ficheiros", + "album_user_left": "Saíu do {album}", + "album_user_removed": "Utilizador {user} removido", + "album_with_link_access": "Permite o acesso a fotos e pessoas deste álbum por qualquer pessoa com o link.", + "albums": "Álbuns", + "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", + "all": "Todos", + "all_albums": "Todos os álbuns", + "all_people": "Todas as pessoas", + "all_videos": "Todos os vídeos", + "allow_dark_mode": "Permitir modo escuro", + "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", + "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.", + "api_key_empty": "O nome da chave a API não pode estar vazio", + "api_keys": "Chaves de API", + "app_settings": "Definições da Aplicação", + "appears_in": "Aparece em", + "archive": "Arquivo", + "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", + "archive_size": "Tamanho do arquivo", + "archive_size_description": "Configure o tamanho do arquivo para transferências (em GiB)", + "archived": "Arquivado", + "archived_count": "{count, plural, one {#Arquivado # item} other {Arquivados # itens}}", + "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_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_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...", + "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", + "assets_added_to_name_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", + "assets_count": "{count, plural, one {# ficheiro} other {# ficheiros}}", + "assets_moved_to_trash": "{count, plural, one {# ativo enviado} other {# ativos enviados}} para a lixeira", + "assets_moved_to_trash_count": "{count, plural, one {# ficheiro movido} other {# ficheiros movidos}} para a reciclagem", + "assets_permanently_deleted_count": "{count, plural, one {# ficheiro} other {# ficheiros}} eliminados permanentemente", + "assets_removed_count": "{count, plural, one {# ficheiro eliminado} other {# ficheiros eliminados}}", + "assets_restore_confirmation": "Tem a certeza de que quer recuperar todos os ficheiros apagados? Não é possível anular esta ação! Tenha em conta de que quaisquer ficheiros indisponíveis não podem ser restaurados desta forma.", + "assets_restored_count": "{count, plural, one {# ficheiro restaurado} other {# ficheiros restaurados}}", + "assets_trashed_count": "{count, plural, one {# ficheiro enviado} other {# ficheiros enviados}} para a reciclagem", + "assets_were_part_of_album_count": "{count, plural, one {O ficheiro já fazia} other {Os ficheiros já faziam}} parte do álbum", + "authorized_devices": "Dispositivos Autorizados", + "back": "Voltar", + "back_close_deselect": "Voltar, fechar ou desmarcar", + "backward": "Para trás", + "birthdate_saved": "Data de nascimento guardada com sucesso", + "birthdate_set_description": "A data de nascimento é utilizada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", + "blurred_background": "Fundo desfocado", + "bugs_and_feature_requests": "Relatar problemas ou pedir novas funcionalidades", + "build": "Versão de compilação", + "build_image": "Imagem de compilação", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Esta ação mantém o maior ficheiro de cada grupo e elimina permanentemente todos os outros duplicados. Não é possível anular esta ação!", + "bulk_keep_duplicates_confirmation": "Tem a certeza de que deseja manter {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto resolverá todos os grupos duplicados sem eliminar nada.", + "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a reciclagem {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto manterá o maior ficheiro de cada grupo e irá mover para a reciclagem todos os outros duplicados.", + "buy": "Comprar Immich", + "camera": "Câmara", + "camera_brand": "Marca da câmara", + "camera_model": "Modelo da câmara", + "cancel": "Cancelar", + "cancel_search": "Cancelar pesquisa", + "cannot_merge_people": "Não foi possível unir pessoas", + "cannot_undo_this_action": "Não é possível anular esta ação!", + "cannot_update_the_description": "Não foi possível atualizar a descrição", + "cant_apply_changes": "Não é possível aplicar alterações", + "cant_get_faces": "Não foi possível obter faces", + "cant_search_people": "Não foi possível pesquisar pessoas", + "cant_search_places": "Não foi possível pesquisar lugares", + "change_date": "Alterar data", + "change_expiration_time": "Alterar o prazo de validade", + "change_location": "Alterar localização", + "change_name": "Alterar nome", + "change_name_successfully": "Nome alterado com sucesso", + "change_password": "Alterar a palavra-passe", + "change_password_description": "Esta é a primeira vez que está a entrar no sistema ou um pedido foi feito para alterar a sua palavra-passe. Insira a nova palavra-passe abaixo.", + "change_your_password": "Alterar a sua palavra-passe", + "changed_visibility_successfully": "Visibilidade alterada com sucesso", + "check_all": "Verificar tudo", + "check_logs": "Verificar registos", + "choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir", + "city": "Cidade", + "clear": "Limpar", + "clear_all": "Limpar tudo", + "clear_all_recent_searches": "Limpar todas as pesquisas recentes", + "clear_message": "Limpar mensagem", + "clear_value": "Limpar valor", + "clockwise": "Sentido horário", + "close": "Fechar", + "collapse": "Colapsar", + "collapse_all": "Colapsar tudo", + "color": "Cor", + "color_theme": "Esquema de cores", + "comment_deleted": "Comentário eliminado", + "comment_options": "Opções de comentário", + "comments_and_likes": "Comentários e gostos", + "comments_are_disabled": "Comentários estão desativados", + "confirm": "Confirmar", + "confirm_admin_password": "Confirmar palavra-passe de administrador", + "confirm_delete_shared_link": "Tem a certeza de que deseja eliminar este link partilhado?", + "confirm_password": "Confirmar a palavra-passe", + "contain": "Ajustar", + "context": "Contexto", + "continue": "Continuar", + "copied_image_to_clipboard": "Imagem copiada para a área de transferência.", + "copied_to_clipboard": "Copiado para a área de transferência!", + "copy_error": "Copiar erro", + "copy_file_path": "Copiar caminho do ficheiro", + "copy_image": "Copiar Imagem", + "copy_link": "Copiar link", + "copy_link_to_clipboard": "Copiar link para a área de transferência", + "copy_password": "Copiar palavra-passe", + "copy_to_clipboard": "Copiar para a área de transferência", + "country": "País", + "cover": "Preencher", + "covers": "Capas", + "create": "Criar", + "create_album": "Criar álbum", + "create_library": "Criar biblioteca", + "create_link": "Criar link", + "create_link_to_share": "Criar link para partilhar", + "create_link_to_share_description": "Permitir a visualização desta(s) imagem(s) a qualquer pessoa com o link", + "create_new_person": "Criar nova pessoa", + "create_new_person_hint": "Associe os ficheiros a uma nova pessoa", + "create_new_user": "Criar novo utilizador", + "create_tag": "Criar etiqueta", + "create_tag_description": "Criar uma nova etiqueta. Para etiquetas compostas, introduza o caminho completo, incluindo as barras.", + "create_user": "Criar utilizador", + "created": "Criado", + "current_device": "Dispositivo atual", + "custom_locale": "Localização Personalizada", + "custom_locale_description": "Formatar datas e números baseados na língua e na região", + "dark": "Escuro", + "date_after": "Data após", + "date_and_time": "Data e Hora", + "date_before": "Data antes", + "date_of_birth_saved": "Data de nascimento guardada com sucesso", + "date_range": "Intervalo de datas", + "day": "Dia", + "deduplicate_all": "Limpar todos os itens duplicados", + "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_key": "Eliminar chave", + "delete_library": "Eliminar Biblioteca", + "delete_link": "Eliminar link", + "delete_shared_link": "Eliminar link de partilha", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Tem a certeza de que pretende eliminar a etiqueta {tagName} ?", + "delete_user": "Eliminar utilizador", + "deleted_shared_link": "Link de partilha eliminado", + "deletes_missing_assets": "Elimina os ficheiros que estejam em falta no disco", + "description": "Descrição", + "details": "Detalhes", + "direction": "Direção", + "disabled": "Desativado", + "disallow_edits": "Não permitir edições", + "discord": "Discord", + "discover": "Descobrir", + "dismiss_all_errors": "Dispensar todos os erros", + "dismiss_error": "Dispensar erro", + "display_options": "Opções de exibição", + "display_order": "Ordem de exibição", + "display_original_photos": "Exibir fotos originais", + "display_original_photos_setting_description": "Preferir a exibição da foto original ao visualizar um ficheiro em vez de miniaturas quando o ficheiro original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", + "do_not_show_again": "Não mostrar esta mensagem novamente", + "documentation": "Documentação", + "done": "Feito", + "download": "Transferir", + "download_include_embedded_motion_videos": "Vídeos incorporados", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um ficheiro separado", + "download_settings": "Transferir", + "download_settings_description": "Gerir definições relacionadas com a transferência de ficheiros", + "downloading": "A transferir", + "downloading_asset_filename": "A transferir o ficheiro {filename}", + "drop_files_to_upload": "Solte os ficheiros em qualquer lugar para os enviar", + "duplicates": "Itens duplicados", + "duplicates_description": "Marque cada grupo indicando quais ficheiros, se algum, são duplicados", + "duration": "Duração", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "Editar", + "edit_album": "Editar álbum", + "edit_avatar": "Editar imagem de perfil", + "edit_date": "Editar data", + "edit_date_and_time": "Editar data e hora", + "edit_exclusion_pattern": "Editar o padrão de exclusão", + "edit_faces": "Editar rostos", + "edit_import_path": "Editar caminho de importação", + "edit_import_paths": "Editar caminhos de importação", + "edit_key": "Editar chave", + "edit_link": "Editar link", + "edit_location": "Editar Localização", + "edit_name": "Editar nome", + "edit_people": "Editar pessoas", + "edit_tag": "Editar etiqueta", + "edit_title": "Editar Título", + "edit_user": "Editar utilizador", + "edited": "Editado", + "editor": "Editor", + "editor_close_without_save_prompt": "As alterações não serão guardadas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Relação de aspeto", + "editor_crop_tool_h2_rotation": "Rotação", + "email": "E-mail", + "empty": "", + "empty_album": "", + "empty_trash": "Esvaziar reciclagem", + "empty_trash_confirmation": "Tem a certeza de que deseja esvaziar a reciclagem? Isto removerá todos os ficheiros da reciclagem do Immich permanentemente.\nNão é possível anular esta ação!", + "enable": "Ativar", + "enabled": "Ativado", + "end_date": "Data final", + "error": "Erro", + "error_loading_image": "Erro ao carregar a imagem", + "error_title": "Erro - Algo correu mal", + "errors": { + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo ficheiro", + "cannot_navigate_previous_asset": "Não foi possível navegar para o ficheiro anterior", + "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não foi possível alterar o favorito deste ficheiro", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# ficheiro} other {# ficheiros}}", + "cant_get_faces": "Não foi possível obter os rostos", + "cant_get_number_of_comments": "Não foi possível obter o número de comentários", + "cant_search_people": "Não foi possível pesquisar pessoas", + "cant_search_places": "Não foi possível pesquisar locais", + "cleared_jobs": "Tarefas eliminadas para: {job}", + "error_adding_assets_to_album": "Erro ao adicionar ficheiros ao álbum", + "error_adding_users_to_album": "Erro ao adicionar utilizador ao álbum", + "error_deleting_shared_user": "Erro ao apagar o utilizador partilhado", + "error_downloading": "Erro ao transferir {filename}", + "error_hiding_buy_button": "Erro ao esconder botão de compra", + "error_removing_assets_from_album": "Erro ao eliminar ficheiros do álbum, verifique a consola para mais detalhes", + "error_selecting_all_assets": "Erro ao selecionar todos os ficheiros", + "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", + "failed_job_command": "Comando {command} falhou para a tarefa: {job}", + "failed_to_create_album": "Não foi possível criar álbum", + "failed_to_create_shared_link": "Não foi possível criar o link partilhado", + "failed_to_edit_shared_link": "Não foi possível editar o link partilhado", + "failed_to_get_people": "Não foi possível obter pessoas", + "failed_to_load_asset": "Não foi possível ler o ficheiro", + "failed_to_load_assets": "Não foi possível ler ficheiros", + "failed_to_load_people": "Não foi possível carregar pessoas", + "failed_to_remove_product_key": "Não foi possível remover chave de produto", + "failed_to_stack_assets": "Não foi possível empilhar os ficheiros", + "failed_to_unstack_assets": "Não foi possível desempilhar ficheiros", + "import_path_already_exists": "Este caminho de importação já existe.", + "incorrect_email_or_password": "Email ou palavra-passe incorretos", + "paths_validation_failed": "A validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixeis transparentes. Por favor amplie e/ou mova a imagem.", + "quota_higher_than_disk_size": "Definiu uma quota maior do que o tamanho do disco", + "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", + "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", + "unable_to_add_assets_to_shared_link": "Não foi possível adicionar os ficheiros ao link partilhado", + "unable_to_add_comment": "Não foi possível adicionar o comentário", + "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", + "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", + "unable_to_add_partners": "Não foi possível adicionar parceiros", + "unable_to_add_remove_archive": "Não foi possível {archived, select, true {remover o ficheiro de} other {adicionar o ficheiro}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar ficheiro aos} other {remover ficheiro dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", + "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", + "unable_to_change_date": "Não foi possível alterar a data", + "unable_to_change_favorite": "Não foi possível mudar o favorito do ficheiro", + "unable_to_change_location": "Não foi possível alterar a localização", + "unable_to_change_password": "Não foi possível alterar a palavra-passe", + "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "Não foi possível completar o início de sessão com OAuth", + "unable_to_connect": "Não é possível ligar", + "unable_to_connect_to_server": "Não foi possível ligar ao servidor", + "unable_to_copy_to_clipboard": "Não foi possível copiar para a área de transferência, certifique-se de que está a aceder à pagina através de https", + "unable_to_create_admin_account": "Não foi possível criar conta de administrador", + "unable_to_create_api_key": "Não foi possível criar uma nova Chave de API", + "unable_to_create_library": "Não foi possível criar a biblioteca", + "unable_to_create_user": "Não foi possível criar o utilizador", + "unable_to_delete_album": "Não foi possível eliminar o álbum", + "unable_to_delete_asset": "Não foi possível eliminar o ficheiro", + "unable_to_delete_assets": "Erro ao eliminar ficheiros", + "unable_to_delete_exclusion_pattern": "Não foi possível eliminar o padrão de exclusão", + "unable_to_delete_import_path": "Não foi possível eliminar o caminho de importação", + "unable_to_delete_shared_link": "Não foi possível eliminar o link compartilhado", + "unable_to_delete_user": "Não foi possível eliminar o utilizador", + "unable_to_download_files": "Não foi possível transferir ficheiros", + "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", + "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", + "unable_to_empty_trash": "Não foi possível esvaziar a reciclagem", + "unable_to_enter_fullscreen": "Não foi possível entrar em modo de ecrã inteiro", + "unable_to_exit_fullscreen": "Não foi possível sair do modo de ecrã inteiro", + "unable_to_get_comments_number": "Não foi possível obter número de comentários", + "unable_to_get_shared_link": "Não foi possível obter link partilhado", + "unable_to_hide_person": "Não foi possível esconder a pessoa", + "unable_to_link_motion_video": "Não foi possível relacionar o video animado", + "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", + "unable_to_load_album": "Não foi possível carregar o álbum", + "unable_to_load_asset_activity": "Não foi possível carregar a atividade do ficheiro", + "unable_to_load_items": "Não foi possível carregar os itens", + "unable_to_load_liked_status": "Não foi possível carregar o estado de gostos", + "unable_to_log_out_all_devices": "Não foi possível terminar a sessão em todos os dispositivos", + "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", + "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", + "unable_to_play_video": "Não foi possível reproduzir o vídeo", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir ficheiros para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não foi possível reatribuir os ficheiros a uma nova pessoa", + "unable_to_refresh_user": "Não foi possível recarregar o utilizador", + "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", + "unable_to_remove_api_key": "Não foi possível remover a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não foi possível remover os ficheiros do link partilhado", + "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Não foi possível remover ficheiros indisponíveis", + "unable_to_remove_library": "Não foi possível remover a biblioteca", + "unable_to_remove_partner": "Não foi possível remover parceiro", + "unable_to_remove_reaction": "Não foi possível remover a reação", + "unable_to_remove_user": "", + "unable_to_repair_items": "Não foi possível reparar os itens", + "unable_to_reset_password": "Não foi possível redefinir a palavra-passe", + "unable_to_resolve_duplicate": "Não foi possível resolver as duplicidades", + "unable_to_restore_assets": "Não foi possível restaurar ficheiros", + "unable_to_restore_trash": "Não foi possível restaurar itens da reciclagem", + "unable_to_restore_user": "Não foi possível restaurar utilizador", + "unable_to_save_album": "Não foi possível guardar o álbum", + "unable_to_save_api_key": "Não foi possível guardar a Chave de API", + "unable_to_save_date_of_birth": "Não foi possível guardar a data de nascimento", + "unable_to_save_name": "Não foi possível guardar o nome", + "unable_to_save_profile": "Não foi possível guardar o perfil", + "unable_to_save_settings": "Não foi possível guardar as definições", + "unable_to_scan_libraries": "Não foi possível analisar as bibliotecas", + "unable_to_scan_library": "Não foi possível analisar a biblioteca", + "unable_to_set_feature_photo": "Não foi possível definir a foto de destaque", + "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", + "unable_to_submit_job": "Não foi possível enviar a tarefa", + "unable_to_trash_asset": "Não foi possível enviar o ficheiro para a reciclagem", + "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_unlink_motion_video": "Não foi possível remover a relação com o video animado", + "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", + "unable_to_update_album_info": "Não foi possível atualizar informações do álbum", + "unable_to_update_library": "Não foi possível atualizar a biblioteca", + "unable_to_update_location": "Não foi possível atualizar a localização", + "unable_to_update_settings": "Não foi possível atualizar as definições", + "unable_to_update_timeline_display_status": "Não foi possível atualizar o modo de visualização da linha do tempo", + "unable_to_update_user": "Não foi possível atualizar o utilizador", + "unable_to_upload_file": "Não foi possível carregar o ficheiro" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "Exif", + "exit_slideshow": "Sair da apresentação", + "expand_all": "Expandir tudo", + "expire_after": "Expira depois de", + "expired": "Expirou", + "expires_date": "Expira em {date}", + "explore": "Explorar", + "explorer": "Explorador", + "export": "Exportar", + "export_as_json": "Exportar como JSON", + "extension": "Extensão", + "external": "Externo", + "external_libraries": "Bibliotecas externas", + "face_unassigned": "Sem atribuição", + "failed_to_get_people": "Falha ao carregar as pessoas", + "favorite": "Favorito", + "favorite_or_unfavorite_photo": "Marcar ou desmarcar a foto como favorita", + "favorites": "Favoritos", + "feature": "", + "feature_photo_updated": "Foto principal atualizada", + "featurecollection": "", + "features": "Funcionalidades", + "features_setting_description": "Configurar as funcionalidades da aplicação", + "file_name": "Nome do ficheiro", + "file_name_or_extension": "Nome do ficheiro ou extensão", + "filename": "Nome do ficheiro", + "files": "", + "filetype": "Tipo de ficheiro", + "filter_people": "Filtrar pessoas", + "find_them_fast": "Encontre-as mais rapidamente pelo nome numa pesquisa", + "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", + "folders_feature_description": "Navegar na vista de pastas por fotos e vídeos no sistema de ficheiros", + "force_re-scan_library_files": "Forçar uma nova análise de todos os ficheiros da biblioteca", + "forward": "Para a frente", + "general": "Geral", + "get_help": "Obter Ajuda", + "getting_started": "Primeiros Passos", + "go_back": "Regressar", + "go_to_search": "Ir para a pesquisa", + "go_to_share_page": "Ir para a página de compartilhamento", + "group_albums_by": "Agrupar álbuns por...", + "group_no": "Sem agrupamento", + "group_owner": "Agrupar por dono", + "group_year": "Agrupar por ano", + "has_quota": "Tem quota", + "hi_user": "Olá {name} ({email})", + "hide_all_people": "Ocultar todas as pessoas", + "hide_gallery": "Ocultar galeria", + "hide_named_person": "Ocultar pessoa {name}", + "hide_password": "Ocultar palavra-passe", + "hide_person": "Ocultar pessoa", + "hide_unnamed_people": "Ocultar pessoas sem nome", + "host": "Host", + "hour": "Hora", + "image": "Imagem", + "image_alt_text_date": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} em {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} e {person2} em {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", + "image_alt_text_date_place": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} em {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} em {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} e {person2} em {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", + "img": "", + "immich_logo": "Logotipo do Immich", + "immich_web_interface": "Interface Web do Immich", + "import_from_json": "Importar a partir de JSON", + "import_path": "Caminho de importação", + "in_albums": "Em {count, plural, one {# álbum} other {# álbuns}}", + "in_archive": "Arquivado", + "include_archived": "Incluir arquivados", + "include_shared_albums": "Incluir álbuns partilhados", + "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", + "individual_share": "Partilha individual", + "info": "Informações", + "interval": { + "day_at_onepm": "Todos os dias, às 13:00", + "hours": "A cada {hours, plural, one {hora} other {{hours, number} horas}}", + "night_at_midnight": "Todas as noites, à meia noite", + "night_at_twoam": "Todas as noites, às 02:00" + }, + "invite_people": "Convidar Pessoas", + "invite_to_album": "Convidar para o álbum", + "items_count": "{count, plural, one {item #} other {itens #}}", + "job_settings_description": "", + "jobs": "Tarefas", + "keep": "Manter", + "keep_all": "Manter Todos", + "keyboard_shortcuts": "Atalhos do teclado", + "language": "Idioma", + "language_setting_description": "Selecione o seu Idioma preferido", + "last_seen": "Visto pela ultima vez", + "latest_version": "Versão mais recente", + "latitude": "Latitude", + "leave": "Sair", + "let_others_respond": "Permitir respostas", + "level": "Nível", + "library": "Biblioteca", + "library_options": "Opções da biblioteca", + "light": "Claro", + "like_deleted": "Gosto removido", + "link_motion_video": "Relacionar video animado", + "link_options": "Opções do Link", + "link_to_oauth": "Link do OAuth", + "linked_oauth_account": "Conta OAuth Associada", + "list": "Lista", + "loading": "A Carregar", + "loading_search_results_failed": "Não foi possível carregar os resultados da pesquisa", + "log_out": "Sair", + "log_out_all_devices": "Terminar a sessão de todos os dispositivos", + "logged_out_all_devices": "Sessão terminada em todos os dispositivos", + "logged_out_device": "Sessão terminada no dispositivo", + "login": "Iniciar sessão", + "login_has_been_disabled": "Início de sessão foi desativado.", + "logout_all_device_confirmation": "Tem a certeza de que deseja terminar a sessão em todos os dispositivos?", + "logout_this_device_confirmation": "Tem a certeza de que deseja terminar a sessão deste dispositivo?", + "longitude": "Longitude", + "look": "Estilo", + "loop_videos": "Repetir vídeos", + "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", + "main_branch_warning": "Está a utilizar uma versão de desenvolvimento, recomendamos vivamente que utilize uma versão estável!", + "make": "Marca", + "manage_shared_links": "Gerir links partilhados", + "manage_sharing_with_partners": "Gerir partilha com parceiros", + "manage_the_app_settings": "Gerir definições da aplicação", + "manage_your_account": "Gerir a sua conta", + "manage_your_api_keys": "Gerir as suas Chaves de API", + "manage_your_devices": "Gerir os seus dispositivos com sessão iniciada", + "manage_your_oauth_connection": "Gerir a sua ligação ao OAuth", + "map": "Mapa", + "map_marker_for_images": "Marcador no mapa para fotos tiradas em {city}, {country}", + "map_marker_with_image": "Marcador de mapa com imagem", + "map_settings": "Definições do mapa", + "matches": "Correspondências", + "media_type": "Tipo de média", + "memories": "Memórias", + "memories_setting_description": "Gerir o que vê nas suas memórias", + "memory": "Memória", + "memory_lane_title": "Memórias {title}", + "menu": "Menu", + "merge": "Unir", + "merge_people": "Unir pessoas", + "merge_people_limit": "Só é possível unir até 5 rostos de cada vez", + "merge_people_prompt": "Tem a certeza de que deseja unir estas pessoas? Esta ação é irreversível.", + "merge_people_successfully": "Pessoas unidas com sucesso", + "merged_people_count": "Unidas {count, plural, one {# pessoa} other {# pessoas}}", + "minimize": "Minimizar", + "minute": "Minuto", + "missing": "Em falta", + "model": "Modelo", + "month": "Mês", + "more": "Mais", + "moved_to_trash": "Enviado para a reciclagem", + "my_albums": "Os meus álbuns", + "name": "Nome", + "name_or_nickname": "Nome ou alcunha", + "never": "Nunca", + "new_album": "Novo Álbum", + "new_api_key": "Nova Chave de API", + "new_password": "Nova palavra-passe", + "new_person": "Nova Pessoa", + "new_user_created": "Novo utilizador criado", + "new_version_available": "NOVA VERSÃO DISPONÍVEL", + "newest_first": "Mais recente primeiro", + "next": "Avançar", + "next_memory": "Próxima memória", + "no": "Não", + "no_albums_message": "Crie um álbum para organizar as suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que ainda não tem nenhum álbum.", + "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", + "no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO", + "no_duplicates_found": "Nenhum item duplicado foi encontrado.", + "no_exif_info_available": "Sem informações exif disponíveis", + "no_explore_results_message": "Carregue mais fotos para explorar a sua coleção.", + "no_favorites_message": "Adicione aos favoritos para encontrar as suas melhores fotos e vídeos rapidamente", + "no_libraries_message": "Crie uma biblioteca externa para ver as suas fotos e vídeos", + "no_name": "Sem nome", + "no_places": "Sem lugares", + "no_results": "Sem resultados", + "no_results_description": "Tente um sinónimo ou uma palavra-chave mais comum", + "no_shared_albums_message": "Crie um álbum para partilhar fotos e vídeos com pessoas na sua rede", + "not_in_any_album": "Não está em nenhum álbum", + "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", + "note_unlimited_quota": "Nota: Escreva 0 para quota ilimitada", + "notes": "Notas", + "notification_toggle_setting_description": "Ativar notificações por e-mail", + "notifications": "Notificações", + "notifications_setting_description": "Gerir notificações", + "oauth": "OAuth", + "official_immich_resources": "Recursos oficiais do Immich", + "offline": "Offline", + "offline_paths": "Caminhos offline", + "offline_paths_description": "Estes resultados podem ser devidos a ficheiros eliminados manualmente e que não fazem parte de uma biblioteca externa.", + "ok": "Ok", + "oldest_first": "Mais antigo primeiro", + "onboarding": "Integração", + "onboarding_privacy_description": "As seguintes funcionalidades opcionais dependem de serviços externos e podem ser desativados a qualquer momento nas definições de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Pode alterar isto mais tarde nas suas definições.", + "onboarding_welcome_description": "Vamos configurar a sua instância com algumas definições comuns.", + "onboarding_welcome_user": "Bem-vindo(a), {user}", + "online": "Online", + "only_favorites": "Apenas favoritos", + "only_refreshes_modified_files": "Apenas recarrega ficheiros modificados", + "open_in_map_view": "Abrir na visualização de mapa", + "open_in_openstreetmap": "Abrir no OpenStreetMap", + "open_the_search_filters": "Abrir os filtros de pesquisa", + "options": "Opções", + "or": "ou", + "organize_your_library": "Organizar a sua biblioteca", + "original": "original", + "other": "Outro", + "other_devices": "Outros dispositivos", + "other_variables": "Outras variáveis", + "owned": "Seu", + "owner": "Dono", + "partner": "Parceiro", + "partner_can_access": "{partner} pode aceder", + "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Eliminados", + "partner_can_access_location": "A localização onde as fotos foram tiradas", + "partner_sharing": "Partilha com Parceiro", + "partners": "Parceiros", + "password": "Palavra-passe", + "password_does_not_match": "As palavras-passe não condizem", + "password_required": "A palavra-passe é obrigatória", + "password_reset_success": "Palavra-passe redefinida com sucesso", + "past_durations": { + "days": "{days, plural, one {Último dia} other {# últimos dias}}", + "hours": "Últimas {hours, plural, one {horas} other {# horas}}", + "years": "{years, plural, one {Último ano} other {Últimos # anos}}" + }, + "path": "Caminho", + "pattern": "Padrão", + "pause": "Pausa", + "pause_memories": "Pausar memórias", + "paused": "Em Pausa", + "pending": "Pendente", + "people": "Pessoas", + "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", + "people_feature_description": "Navegar por fotos e vídeos agrupados por pessoas", + "people_sidebar_description": "Exibir o link Pessoas na barra lateral", + "perform_library_tasks": "", + "permanent_deletion_warning": "Aviso de eliminação permanente", + "permanent_deletion_warning_setting_description": "Exibir um aviso ao eliminar ficheiros de forma permanente", + "permanently_delete": "Eliminar permanentemente", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {ficheiro} other {ficheiros}}", + "permanently_delete_assets_prompt": "Tem a certeza de que deseja eliminar permanentemente {count, plural, one {este ficheiro?} other {estes # ficheiros?}} Esta ação também removerá {count, plural, one {isto do álbum} other {isto dos álbuns}}.", + "permanently_deleted_asset": "Ficheiro eliminado permanentemente", + "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", + "permanently_deleted_assets_count": "{count, plural, one {# Ficheiro eliminado} other {# Ficheiros eliminados}} permanentemente", + "person": "Pessoa", + "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", + "photo_shared_all_users": "Parece que partilhou as suas fotos com todos os utilizadores ou não tem nenhum utilizador para partilhar.", + "photos": "Fotos", + "photos_and_videos": "Fotos & Vídeos", + "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", + "photos_from_previous_years": "Fotos de anos anteriores", + "pick_a_location": "Selecione uma localização", + "place": "Lugar", + "places": "Lugares", + "play": "Reproduzir", + "play_memories": "Reproduzir memórias", + "play_motion_photo": "Reproduzir foto em movimento", + "play_or_pause_video": "Reproduzir ou Pausar vídeo", + "point": "", + "port": "Porta", + "preset": "Predefinição", + "preview": "Pré-visualizar", + "previous": "Anterior", + "previous_memory": "Memória anterior", + "previous_or_next_photo": "Foto anterior ou próxima", + "primary": "Primário", + "privacy": "Privacidade", + "profile_image_of_user": "Imagem de perfil de {user}", + "profile_picture_set": "Foto de perfil definida.", + "public_album": "Álbum público", + "public_share": "Partilhar Publicamente", + "purchase_account_info": "Apoiante", + "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", + "purchase_activated_time": "Ativado em {date, date}", + "purchase_activated_title": "A sua chave foi ativada com sucesso", + "purchase_button_activate": "Ativar", + "purchase_button_buy": "Comprar", + "purchase_button_buy_immich": "Comprar Immich", + "purchase_button_never_show_again": "Não mostrar de novo", + "purchase_button_reminder": "Relembrar-me daqui a 30 dias", + "purchase_button_remove_key": "Remover chave", + "purchase_button_select": "Selecionar", + "purchase_failed_activation": "Não foi possível ativar! Verifique o seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa individual", + "purchase_individual_description_2": "Status de apoiante", + "purchase_individual_title": "Particular", + "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", + "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", + "purchase_lifetime_description": "Compra vitalícia", + "purchase_option_title": "OPÇÕES DE COMPRA", + "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", + "purchase_panel_info_2": "Como estamos comprometidos a não adicionar acesso pago, esta compra não lhe dará acesso a nenhuma funcionalidade adicional do Immich. Contamos com utilizadores como você para dar suporte ao desenvolvimento contínuo do Immich.", + "purchase_panel_title": "Apoie o projeto", + "purchase_per_server": "Por servidor", + "purchase_per_user": "Por utilizador", + "purchase_remove_product_key": "Remover chave de produto", + "purchase_remove_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto?", + "purchase_remove_server_product_key": "Remover chave do produto do servidor", + "purchase_remove_server_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto do servidor?", + "purchase_server_description_1": "Para o servidor inteiro", + "purchase_server_description_2": "Status de apoiante", + "purchase_server_title": "Servidor", + "purchase_settings_server_activated": "A chave de produto do servidor é gerida pelo administrador", + "range": "", + "rating": "Classificação por estrelas", + "rating_clear": "Limpar classificação", + "rating_count": "{count, plural, one {# estrela} other {# estrelas}}", + "rating_description": "Mostrar a classificação EXIF no painel de informações", + "raw": "", + "reaction_options": "Opções de reação", + "read_changelog": "Ler Novidades", + "reassign": "Reatribuir", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# ficheiro} other {# ficheiros}} para {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuído {count, plural, one {# ficheiro} other {# ficheiros}} a uma nova pessoa", + "reassing_hint": "Atribuir ficheiros selecionados a uma pessoa existente", + "recent": "Recentes", + "recent_searches": "Pesquisas recentes", + "refresh": "Atualizar", + "refresh_encoded_videos": "Atualizar vídeos codificados", + "refresh_faces": "Atualizar rostos", + "refresh_metadata": "Atualizar metadados", + "refresh_thumbnails": "Atualizar miniaturas", + "refreshed": "Atualizado", + "refreshes_every_file": "Recarrega todos os ficheiros já existentes e novos", + "refreshing_encoded_video": "A atualizar vídeo codificado", + "refreshing_faces": "A atualizar rostos", + "refreshing_metadata": "A atualizar metadados", + "regenerating_thumbnails": "A atualizar miniaturas", + "remove": "Remover", + "remove_assets_album_confirmation": "Tem a certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} deste link partilhado?", + "remove_assets_title": "Remover ficheiros?", + "remove_custom_date_range": "Remover intervalo de datas personalizado", + "remove_deleted_assets": "Remover ficheiros indisponíveis", + "remove_from_album": "Remover do álbum", + "remove_from_favorites": "Remover dos favoritos", + "remove_from_shared_link": "Remover do link partilhado", + "remove_user": "Remover utilizador", + "removed_api_key": "Foi removida a Chave de API: {name}", + "removed_from_archive": "Removido do arquivo", + "removed_from_favorites": "Removido dos favoritos", + "removed_from_favorites_count": "{count, plural, other {Removidos #}} dos favoritos", + "removed_tagged_assets": "Removida a etiqueta de {count, plural, one {# ficheiro} other {# ficheiros}}", + "rename": "Mudar o nome", + "repair": "Reparar", + "repair_no_results_message": "Ficheiros em falta ou não monitorizados irão aparecer aqui", + "replace_with_upload": "Substituir pelo ficheiro carregado", + "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", + "reset": "Redefinir", + "reset_password": "Redefinir palavra-passe", + "reset_people_visibility": "Redefinir pessoas ocultas", + "reset_settings_to_default": "", + "reset_to_default": "Repor predefinições", + "resolve_duplicates": "Resolver itens duplicados", + "resolved_all_duplicates": "Todos os itens duplicados resolvidos", + "restore": "Restaurar", + "restore_all": "Restaurar tudo", + "restore_user": "Restaurar utilizador", + "restored_asset": "Ficheiro restaurado", + "resume": "Continuar", + "retry_upload": "Tentar carregar novamente", + "review_duplicates": "Rever itens duplicados", + "role": "Função", + "role_editor": "Editor", + "role_viewer": "Visualizador", + "save": "Guardar", + "saved_api_key": "Chave de API guardada", + "saved_profile": "Perfil guardado", + "saved_settings": "Definições guardadas", + "say_something": "Diga alguma coisa", + "scan_all_libraries": "Analisar todas as bibliotecas", + "scan_all_library_files": "Re-analisar todos os ficheiros da biblioteca", + "scan_library": "Analisar", + "scan_new_library_files": "Analisar novos ficheiros na biblioteca", + "scan_settings": "Opções de análise", + "scanning_for_album": "A analisar por álbum...", + "search": "Pesquisar", + "search_albums": "Pesquisar álbuns", + "search_by_context": "Pesquisar por contexto", + "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_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_settings": "Definições de pesquisa", + "search_state": "Pesquisar estado/distrito...", + "search_tags": "Pesquisar etiquetas...", + "search_timezone": "Pesquisar fuso horário...", + "search_type": "Tipo de pesquisa", + "search_your_photos": "Pesquisar fotos", + "searching_locales": "A pesquisar Lugares....", + "second": "Segundo", + "see_all_people": "Ver todas as pessoas", + "select_album_cover": "Escolher capa do álbum", + "select_all": "Selecionar todos", + "select_all_duplicates": "Selecionar todos os itens duplicados", + "select_avatar_color": "Selecionar cor do avatar", + "select_face": "Selecionar rosto", + "select_featured_photo": "Selecionar foto principal", + "select_from_computer": "Selecionar a partir do computador", + "select_keep_all": "Selecionar manter todos", + "select_library_owner": "Selecionar o dono da biblioteca", + "select_new_face": "Selecionar novo rosto", + "select_photos": "Selecionar fotos", + "select_trash_all": "Selecionar todos para reciclagem", + "selected": "Selecionados", + "selected_count": "{count, plural, other {# selecionados}}", + "send_message": "Enviar mensagem", + "send_welcome_email": "Enviar E-mail de boas vindas", + "server": "Servidor", + "server_offline": "Servidor Offline", + "server_online": "Servidor Online", + "server_stats": "Estado do servidor", + "server_version": "Versão do servidor", + "set": "Definir", + "set_as_album_cover": "Definir como capa do álbum", + "set_as_profile_picture": "Definir como foto de perfil", + "set_date_of_birth": "Definir data de nascimento", + "set_profile_picture": "Definir foto de perfil", + "set_slideshow_to_fullscreen": "Apresentação em ecrã inteiro", + "settings": "Definições", + "settings_saved": "Definições guardadas", + "share": "Partilhar", + "shared": "Partilhado", + "shared_by": "Partilhado por", + "shared_by_user": "Partilhado por {user}", + "shared_by_you": "Partilhado por si", + "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções de link partilhado", + "shared_links": "Links partilhados", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", + "shared_with_partner": "Partilhado com {partner}", + "sharing": "Partilha", + "sharing_enter_password": "Por favor, insira a palavra-passe para ver esta página.", + "sharing_sidebar_description": "Exibe o link para Partilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para eliminar o ficheiro permanentemente", + "show_album_options": "Exibir opções do álbum", + "show_albums": "Mostrar álbuns", + "show_all_people": "Mostrar todas as pessoas", + "show_and_hide_people": "Mostrar & ocultar pessoas", + "show_file_location": "Exibir localização do ficheiro", + "show_gallery": "Exibir galeria", + "show_hidden_people": "Exibir pessoas ocultadas", + "show_in_timeline": "Exibir na linha do tempo", + "show_in_timeline_setting_description": "Exibe fotos e vídeos deste utilizador na sua linha do tempo", + "show_keyboard_shortcuts": "Exibir atalhos do teclado", + "show_metadata": "Mostrar metadados", + "show_or_hide_info": "Exibir ou ocultar informações", + "show_password": "Mostrar palavra-passe", + "show_person_options": "Exibir opções da pessoa", + "show_progress_bar": "Exibir barra de progresso", + "show_search_options": "Exibir opções de pesquisa", + "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", + "shuffle": "Aleatório", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostrar um link para a vista na barra lateral", + "sign_out": "Terminar sessão", + "sign_up": "Criar conta", + "size": "Tamanho", + "skip_to_content": "Saltar para o conteúdo", + "skip_to_folders": "Saltar para pastas", + "skip_to_tags": "Saltar para as etiquetas", + "slideshow": "Apresentação", + "slideshow_settings": "Definições de apresentação", + "sort_albums_by": "Ordenar álbuns por...", + "sort_created": "Data de criação", + "sort_items": "Número de itens", + "sort_modified": "Data de modificação", + "sort_oldest": "Foto mais antiga", + "sort_recent": "Foto mais recente", + "sort_title": "Título", + "source": "Fonte", + "stack": "Empilhar", + "stack_duplicates": "Empilhar itens duplicados", + "stack_select_one_photo": "Selecione uma foto principal para a pilha", + "stack_selected_photos": "Empilhar fotos selecionadas", + "stacked_assets_count": "Empilhado {count, plural, one {# ficheiro} other {# ficheiros}}", + "stacktrace": "Stacktrace", + "start": "Iniciar", + "start_date": "Data de início", + "state": "Estado", + "status": "Estado", + "stop_motion_photo": "Parar foto em movimento", + "stop_photo_sharing": "Deixar de partilhar as suas fotos?", + "stop_photo_sharing_description": "{partner} deixará de ter acesso às suas fotos.", + "stop_sharing_photos_with_user": "Deixar de partilhar as fotos com este utilizador", + "storage": "Espaço de armazenamento", + "storage_label": "Rótulo de Armazenamento", + "storage_usage": "Utilizado {used} de {available}", + "submit": "Enviar", + "suggestions": "Sugestões", + "sunrise_on_the_beach": "Nascer do sol na praia", + "support": "Apoio", + "support_and_feedback": "Apoio e feedback", + "support_third_party_description": "A sua instalação do Immich foi empacotada por terceiros. Quaisquer problemas que possa vir a ter poderão ser causados por esse pacote, por isso, em primeiro lugar, relate problemas aos criadores desse pacote utilizando os links abaixo.", + "swap_merge_direction": "Alternar direção da união", + "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar ficheiros", + "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_updated": "Atualizada a etiqueta: {tag}", + "tagged_assets": "Etiquetado {count, plural, one {# ficheiros} other {# ficheiros}}", + "tags": "Etiquetas", + "template": "Modelo", + "theme": "Tema", + "theme_selection": "Selecionar tema", + "theme_selection_description": "Definir automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão unidos", + "third_party_resources": "Recursos de terceiros", + "time_based_memories": "Memórias baseadas no tempo", + "timezone": "Fuso horário", + "to_archive": "Arquivar", + "to_change_password": "Alterar palavra-passe", + "to_favorite": "Favorito", + "to_login": "Iniciar Sessão", + "to_parent": "Subir um nível", + "to_trash": "Reciclagem", + "toggle_settings": "Alternar configurações", + "toggle_theme": "Ativar modo escuro", + "toggle_visibility": "Alternar visibilidade", + "total_usage": "Total utilizado", + "trash": "Reciclagem", + "trash_all": "Mover todos para a reciclagem", + "trash_count": "Reciclar {count, number}", + "trash_delete_asset": "Eliminar ficheiro", + "trash_no_results_message": "Fotos e vídeos enviados para a reciclagem aparecem aqui.", + "trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem são eliminados permanentemente após {days, plural, one {# dia} other {# dias}}.", + "type": "Tipo", + "unarchive": "Desarquivar", + "unarchived": "Restaurado do arquivo", + "unarchived_count": "{count, plural, other {Não arquivado #}}", + "unfavorite": "Remover favorito", + "unhide_person": "Exibir pessoa", + "unknown": "Desconhecido", + "unknown_album": "", + "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", + "unnamed_album": "Álbum sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza de que pretende eliminar este álbum?", + "unnamed_share": "Partilha sem nome", + "unsaved_change": "Alteração não guardada", + "unselect_all": "Limpar seleção", + "unselect_all_duplicates": "Remover seleção de todos os itens duplicados", + "unstack": "Desempilhar", + "unstacked_assets_count": "Desempilhados {count, plural, one {# ficheiro} other {# ficheiros}}", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_decription": "Estes ficheiros não são monitorizados pela aplicação. Podem ser resultados de falhas numa movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", + "up_next": "A seguir", + "updated_password": "Palavra-passe atualizada", + "upload": "Carregar", + "upload_concurrency": "Carregamentos em simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.", + "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", + "upload_skipped_duplicates": "{count, plural, one {# Ignorado ficheiro duplicado} other {# Ignorados ficheiros duplicados}}", + "upload_status_duplicates": "Duplicados", + "upload_status_errors": "Erros", + "upload_status_uploaded": "Enviado", + "upload_success": "Carregamento realizado com sucesso, atualize a página para ver os novos ficheiros carregados.", + "url": "URL", + "usage": "Utilização", + "use_custom_date_range": "Utilizar um intervalo de datas personalizado", + "user": "Utilizador", + "user_id": "ID do utilizador", + "user_liked": "{user} gostou {type, select, photo {desta fotografia} video {deste video} asset {deste ficheiro} other {disto}}", + "user_purchase_settings": "Comprar", + "user_purchase_settings_description": "Gerir a sua compra", + "user_role_set": "Definir {user} como {role}", + "user_usage_detail": "Detalhes de utilização do utilizador", + "username": "Nome de utilizador", + "users": "Utilizadores", + "utilities": "Ferramentas", + "validate": "Validar", + "variables": "Variáveis", + "version": "Versão", + "version_announcement_closing": "O seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão da aplicação. Reserve algum tempo para visitar o histórico de mudanças e garantir que as suas configurações do docker-compose.yml e .env estão atualizadas para evitar qualquer configuração incorreta, especialmente se usar o WatchTower ou qualquer mecanismo que lide com a atualização automática da aplicação.", + "version_history": "Histórico de versões", + "version_history_item": "Instalado {version} em {date}", + "video": "Vídeo", + "video_hover_setting": "Reproduzir vídeo em miniatura quando passar com o cursor por cima", + "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o cursor está sobre o item. Mesmo quando está desativado, a reprodução ainda pode ser iniciada passando sobre o ícone de reproduzir.", + "videos": "Vídeos", + "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", + "view": "Ver", + "view_album": "Ver Álbum", + "view_all": "Ver tudo", + "view_all_users": "Ver todos os utilizadores", + "view_in_timeline": "Ver na linha do tempo", + "view_links": "Ver links", + "view_next_asset": "Ver próximo ficheiro", + "view_previous_asset": "Ver ficheiro anterior", + "view_stack": "Ver pilha", + "viewer": "Visualizar", + "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", + "waiting": "Em fila", + "warning": "Aviso", + "week": "Semana", + "welcome": "Bem-vindo(a)", + "welcome_to_immich": "Bem-vindo(a) ao Immich", + "year": "Ano", + "years_ago": "Há {years, plural, one {# ano} other {# anos}} atrás", + "yes": "Sim", + "you_dont_have_any_shared_links": "Não tem links partilhados", + "zoom_image": "Ampliar/Reduzir imagem" +} diff --git a/web/src/lib/i18n/pt_BR.json b/i18n/pt_BR.json similarity index 84% rename from web/src/lib/i18n/pt_BR.json rename to i18n/pt_BR.json index 725b9daab5..78f5ce7187 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -2,12 +2,12 @@ "about": "Sobre", "account": "Conta", "account_settings": "Configurações da Conta", - "acknowledge": "Confirmar", + "acknowledge": "Entendi", "action": "Ação", "actions": "Ações", "active": "Em execução", "activity": "Atividade", - "activity_changed": "A atividade está {enabled, select, true {enabled} other {disabled}}", + "activity_changed": "A atividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", "add_a_description": "Adicionar uma descrição", "add_a_location": "Adicionar uma localização", @@ -25,9 +25,10 @@ "add_to_shared_album": "Adicionar ao álbum compartilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", - "added_to_favorites_count": "{count, plural, one {{count, number} adicionado(a) aos favoritos} other {{count, number} adicionados(as) aos favoritos}}", + "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que terminam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", + "asset_offline_description": "Este arquivo não foi encontrado na biblioteca externa, então foi enviado para a lixeira. Se o arquivo foi movido para outra pasta dentro da biblioteca, verifique sua linha do tempo para encontrar o arquivo novamente. Para restaurar este arquivo, certifique-se de que o caminho descrito abaixo pode ser acessado pelo Immich e então escaneie a biblioteca.", "authentication_settings": "Configurações de Autenticação", "authentication_settings_description": "Gerenciar senhas, OAuth, e outras configurações de autenticação", "authentication_settings_disable_all": "Tem certeza de que deseja desativar todos os métodos de login? O login será completamente desativado.", @@ -41,35 +42,46 @@ "confirm_email_below": "Para confirmar, digite o {email} abaixo", "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos os rostos? Isso também limpará as pessoas nomeadas.", "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", + "create_job": "Criar tarefa", "crontab_guru": "Guru do Crontab", "disable_login": "Desabilitar login", "disabled": "", - "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da Pesquisa Inteligente", + "duplicate_detection_job_description": "Execute a inteligência artificial 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", "face_detection": "Detecção de rostos", - "face_detection_description": "Detecta rostos em arquivos com inteligência artificial. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os arquivos. \"Ausente\" enfileira arquivos que ainda não foram processados. Os rostos detectados serão enfileirados para reconhecimento facial após a conclusão da detecção de rostos, agrupando-os em pessoas novas ou existentes.", - "facial_recognition_job_description": "Agrupa rostos detectados em pessoas. Esta etapa é executada após a conclusão da detecção de rostos. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira rostos que ainda não têm uma pessoa atribuída.", + "face_detection_description": "Detectar rostos nos arquivos usando aprendizado de máquina. Para vídeos, apenas a miniatura é considerada. ‘Atualizar’ (re)processa todos os arquivos. ‘Resetar’ também limpa todos os dados de rosto atuais. ‘Faltando’ coloca em fila os arquivos que ainda não foram processados. Rostos detectados serão colocados em fila para o Reconhecimento Facial após a conclusão da Detecção de Rostos, agrupando-os em pessoas existentes ou novas.", + "facial_recognition_job_description": "Agrupar rostos detectados em pessoas. Esta etapa é executada após a conclusão da Detecção de Rostos. ‘Resetar’ (re)agrupará todos os rostos. ‘Faltando’ coloca em fila os rostos que não têm uma pessoa atribuída.", "failed_job_command": "O comando {command} falhou para a tarefa: {job}", "force_delete_user_warning": "AVISO: Isso removerá imediatamente o usuário e todos os arquivos. Isso não pode ser desfeito e os arquivos não podem ser recuperados.", "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", + "image_format": "Formato", "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", "image_prefer_embedded_preview": "Prefira visualização incorporada", "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_format": "Formato de visualização", + "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_resolution": "Resolução de visualização", "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizado de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "image_preview_title": "Configurações de pré-visualização", "image_quality": "Qualidade", "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz arquivos maiores. Esta opção afeta as imagens de visualização e miniatura.", + "image_resolution": "Resolução", + "image_resolution_description": "Resoluções mais altas preservam mais detalhes, porém demoram mais para processar, tem um tamanho de arquivo maior e pode reduzir a velocidade do aplicativo.", "image_settings": "Configurações de imagem", "image_settings_description": "Gerenciar a qualidade e resolução das imagens geradas", + "image_thumbnail_description": "Miniatura sem metadados, utilizado quando visualizar um grupos de fotos, como por exemplo, a linha do tempo principal", "image_thumbnail_format": "Formato de miniatura", + "image_thumbnail_quality_description": "Qualidade da miniatura, de 1 a 100. Maior é melhor, mas produz arquivos maiores e pode reduzir a velocidade do aplicativo.", "image_thumbnail_resolution": "Resolução de miniatura", "image_thumbnail_resolution_description": "Usado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "image_thumbnail_title": "Configurações de Miniaturas", "job_concurrency": "{job} simultâneo", + "job_created": "Tarefa criada", "job_not_concurrency_safe": "Esta tarefa não é compatível com simultaneidade.", "job_settings": "Configurações de Tarefa", "job_settings_description": "Gerenciar simultaneidade das tarefas", @@ -95,12 +107,12 @@ "logging_level_description": "Quando ativado, qual nível de log usar.", "logging_settings": "Registros", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de reexecutar a tarefa de 'Pesquisa Inteligente' para todas as imagens ao alterar o modelo.", + "machine_learning_clip_model_description": "O nome de um modelo CLIP listado aqui. Lembre-se de executar novamente a tarefa de 'Pesquisa Inteligente' para todas as imagens após alterar o modelo.", "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 o aprendizado da máquina", + "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_facial_recognition": "Reconhecimento Facial", "machine_learning_facial_recognition_description": "Detectar, reconhecer e agrupar rostos em imagens", @@ -116,29 +128,34 @@ "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 aprendizado de máquina (Machine Learning)", - "machine_learning_settings_description": "Gerenciar recursos e configurações de aprendizado de máquina", + "machine_learning_settings": "Configurações de inteligência artificial", + "machine_learning_settings_description": "Gerenciar recursos e configurações da inteligência artificial", "machine_learning_smart_search": "Pesquisa Inteligente", "machine_learning_smart_search_description": "Buscar imagens semanticamente usando embeddings 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": "URL do servidor de aprendizado de máquina", + "machine_learning_url_description": "URL do servidor de inteligência artificial", "manage_concurrency": "Gerenciar simultaneidade", "manage_log_settings": "Gerenciar configurações de registro", "map_dark_style": "Tema Escuro", "map_enable_description": "Ativar recursos do mapa", "map_gps_settings": "Mapa e Configurações de GPS", "map_gps_settings_description": "Gerenciar Mapa e Configurações de GPS (Geocodificação Reversa)", + "map_implications": "O mapa depende de um serviço externo para funcionar (tiles.immich.cloud)", "map_light_style": "Tema Claro", "map_manage_reverse_geocoding_settings": "Gerenciar configurações de Geocodificação reversa", "map_reverse_geocoding": "Geocodificação reversa", "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", - "map_settings": "Configurações de mapa e GPS", + "map_settings": "Mapa", "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", - "metadata_extraction_job_description": "Extraia informações de metadados de cada arquivo, como GPS e resolução", + "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", + "metadata_faces_import_setting_description": "Importar rostos a partir dos metadados EXIF da imagem e arquivos auxiliares", + "metadata_settings": "Configurações de Metadados", + "metadata_settings_description": "Gerenciar configurações de metadados", "migration_job": "Migração", "migration_job_description": "Migrar miniaturas de arquivos e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL do emissor", "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", - "oauth_mobile_redirect_uri_override_description": "Ative quando 'app.immich:/' for um URI de redirecionamento inválido.", + "oauth_mobile_redirect_uri_override_description": "Ative quando o provedor do OAuth não suportar uma URI de aplicativo, por exemplo '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de assinatura de perfis", "oauth_profile_signing_algorithm_description": "Algoritmo usado para assinar o perfil do usuário.", "oauth_scope": "Escopo", @@ -193,19 +210,22 @@ "password_settings": "Senha de acesso", "password_settings_description": "Gerenciar configurações de login e senha", "paths_validated_successfully": "Todos os caminhos validados com sucesso", + "person_cleanup_job": "Limpeza de pessoas", "quota_size_gib": "Tamanho da cota (GiB)", "refreshing_all_libraries": "Atualizando todas as bibliotecas", "registration": "Registro de Administrador", "registration_description": "Como você é o primeiro usuário no sistema, será designado como o Administrador e será responsável pelas tarefas administrativas. Você também poderá criar usuários adicionais.", - "removing_offline_files": "Removendo arquivos offline", + "removing_deleted_files": "Removendo arquivos offline", "repair_all": "Reparar tudo", "repair_matched_items": "{count, plural, one {# item encontrado} other {# itens encontrados}}", "repaired_items": "{count, plural, one {# item reparado} other {# itens reparados}}", "require_password_change_on_login": "Exigir que o usuário altere a senha no primeiro login", "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", "scanning_library_for_changed_files": "Escaneando a biblioteca em busca de arquivos alterados", "scanning_library_for_new_files": "Escaneando a biblioteca em busca de novos arquivos", + "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)://", @@ -216,7 +236,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 o aprendizado de máquina em arquivos para oferecer suporte à pesquisa inteligente", + "smart_search_job_description": "Execute a inteligência artificial 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", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Gerencie a estrutura de pasta e o nome do arquivo carregado", "storage_template_user_label": "{label} é o Rótulo de Armazenamento do usuário", "system_settings": "Configurações do Sistema", + "tag_cleanup_job": "Limpeza de tags", "theme_custom_css_settings": "CSS customizado", "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", "theme_settings": "Configurações de tema", @@ -249,7 +270,7 @@ "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_accepted_containers": "containers aceitos", + "transcoding_accepted_containers": "Containers aceitos", "transcoding_accepted_containers_description": "Selecione quais formatos de contêiner não precisam ser remixados para MP4. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Aceleração de hardware", "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", "transcoding_hardware_decoding": "Decodificação de hardware", - "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC, QSV e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", + "transcoding_hardware_decoding_setting_description": "Habilita a aceleração de ponta a ponta, em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", "transcoding_hevc_codec": "Codec HEVC", "transcoding_max_b_frames": "Máximo de quadros B", "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de `mais rápidas`.", + "transcoding_preset_preset_description": "Velocidade de compressão. As opções mais lentas produzem arquivos menores e aumentam a qualidade. VP9 ignora as velocidades acima de 'mais rápida'.", "transcoding_reference_frames": "Quadros de referência", "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", @@ -307,10 +328,11 @@ "trash_settings_description": "Gerenciar configurações da lixeira", "untracked_files": "Arquivos não rastreados", "untracked_files_description": "Esses arquivos não são rastreados pelo aplicativo. Eles podem ser o resultado de movimentos malsucedidos, carregamentos interrompidos ou deixados para trás devido a um erro", + "user_cleanup_job": "Limpeza de usuários", "user_delete_delay": "A conta e os arquivos de {user} serão programados para exclusão permanente em {delay, plural, one {# dia} other {# dias}}.", "user_delete_delay_settings": "Excluir atraso", "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um usuário. A tarefa de exclusão de usuário é executada à meia-noite para verificar usuários que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão postos na fila para exclusão permanente imediatamente.", + "user_delete_immediately": "A conta e os arquivos de {user} serão programados para exclusão permanente imediata.", "user_delete_immediately_checkbox": "Adicionar o usuário e seus ativos na fila para serem deletados imediatamente", "user_management": "Gerenciamento de usuários", "user_password_has_been_reset": "A senha do usuário foi redefinida:", @@ -320,7 +342,8 @@ "user_settings": "Configurações do Usuário", "user_settings_description": "Gerenciar configurações do usuário", "user_successfully_removed": "O usuário {email} foi removido com sucesso.", - "version_check_enabled_description": "Ativa verificações periódicas no GitHub para novas versões", + "version_check_enabled_description": "Ativa a verificação de versão", + "version_check_implications": "A verificação de versão depende de uma comunicação periódica com github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", @@ -336,7 +359,8 @@ "album_added": "Álbum adicionado", "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "Tem certeza de que deseja excluir o álbum {album}?\nSe este álbum for compartilhado, outros usuários não poderão mais acessá-lo.", + "album_delete_confirmation": "Tem certeza de que deseja excluir o álbum {album}?", + "album_delete_confirmation_description": "Se este álbum é compartilhado, os outros usuários não conseguiram mais acessá-lo.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", "album_leave_confirmation": "Tem certeza de que deseja sair de {album}?", @@ -344,11 +368,11 @@ "album_options": "Opções de álbum", "album_remove_user": "Remover usuário?", "album_remove_user_confirmation": "Tem certeza de que deseja remover {user}?", - "album_share_no_users": "Parece que você compartilhou este álbum com todos os usuários ou não tem nenhum usuário para compartilhar com ele.", + "album_share_no_users": "Parece que você já compartilhou este álbum com todos os usuários ou não há nenhum usuário para compartilhar.", "album_updated": "Álbum atualizado", "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos recursos", - "album_user_left": "Saída de {album}", - "album_user_removed": "Usuário {user} removido", + "album_user_left": "Saiu do álbum {album}", + "album_user_removed": "Usuário {user} foi removido", "album_with_link_access": "Permitir que qualquer pessoa com o link veja as fotos e as pessoas neste álbum.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", @@ -358,8 +382,9 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permitir que usuários públicos façam download", - "allow_public_user_to_upload": "Permitir que usuários públicos enviem novos ativos", + "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", + "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.", "api_key_empty": "O nome da sua chave de API não deve estar vazio", @@ -368,8 +393,8 @@ "appears_in": "Aparece em", "archive": "Arquivados", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", - "archive_size": "Tamanho do Arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size": "Tamanho do arquivo", + "archive_size_description": "Configure o tamanho do arquivo para baixar (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, one {# Arquivado} other {# Arquivados}}", "are_these_the_same_person": "Essas pessoas são a mesma pessoa?", @@ -377,12 +402,13 @@ "asset_added_to_album": "Adicionado 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} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos não atribuídos", + "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_offline": "Arquivo off-line", - "asset_offline_description": "Este arquivo está offline. O Immich não pode acessar sua localização de arquivo. Certifique-se de que o arquivo esteja disponível e depois escaneie novamente a biblioteca.", + "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...", "assets": "Arquivos", @@ -394,20 +420,21 @@ "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", "assets_permanently_deleted_count": "{count, plural, one {# arquivo excluído permanentemente} other {# arquivos excluídos permanentemente}}", "assets_removed_count": "{count, plural, one {# arquivo removido} other {# arquivos removidos}}", - "assets_restore_confirmation": "Tem certeza de que deseja restaurar todos os seus arquivos na lixeira? Esta ação não pode ser desfeita!", + "assets_restore_confirmation": "Tem certeza de que deseja restaurar todos os seus arquivos na lixeira? Esta ação não pode ser desfeita! Nota: Arquivos externos não podem ser restaurados desta maneira.", "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", "assets_trashed_count": "{count, plural, one {# arquivo movido para a lixeira} other {# arquivos movidos para a lixeira}}", - "assets_were_part_of_album_count": "{count, plural, one {O recurso estava} other {Os recursos estavam}} já fazendo parte do álbum", + "assets_were_part_of_album_count": "{count, plural, one {O arquivo já faz} other {Os arquivos já fazem}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento salva com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa na época de uma foto.", + "birthdate_set_description": "A data de nascimento é usada para calcular a idade da pessoa no momento em que a foto foi tirada.", "blurred_background": "Fundo desfocado", + "bugs_and_feature_requests": "Relatar problemas & Sugestões", "build": "Versão de compilação", "build_image": "Imagem de compilação", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode reverter esta ação!", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja deletar {count, plural, one {# arquivo duplicado} other {em massa # arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e deleta permanentemente todos as outras duplicidades. Você não pode desfazer esta ação!", "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", "buy": "Comprar o Immich", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Limpar todas as buscas recentes", "clear_message": "Limpar mensagem", "clear_value": "Limpar valor", + "clockwise": "Horário", "close": "Fechar", "collapse": "Recolher", "collapse_all": "Colapsar tudo", + "color": "Cor", "color_theme": "Tema de cores", "comment_deleted": "Comentário excluído", "comment_options": "Opções de comentário", @@ -477,6 +506,8 @@ "create_new_person": "Criar nova pessoa", "create_new_person_hint": "Atribuir arquivos selecionados a uma nova pessoa", "create_new_user": "Criar novo usuário", + "create_tag": "Criar tag", + "create_tag_description": "Crie uma nova tag. Para tags compostas, digite o caminho completo da tag, inclusive as barras.", "create_user": "Criar usuário", "created": "Criado", "current_device": "Dispositivo atual", @@ -500,13 +531,17 @@ "delete_library": "Excluir biblioteca", "delete_link": "Excluir link", "delete_shared_link": "Excluir link de compartilhamento", + "delete_tag": "Remover tag", + "delete_tag_confirmation_prompt": "Tem certeza que deseja excluir a tag {tagName} ?", "delete_user": "Excluir usuário", "deleted_shared_link": "Link de compartilhamento excluído", + "deletes_missing_assets": "Excluir arquivos não encontrados", "description": "Descrição", "details": "Detalhes", "direction": "Direção", "disabled": "Desativado", "disallow_edits": "Não permitir edições", + "discord": "Discord", "discover": "Descobrir", "dismiss_all_errors": "Dispensar todos os erros", "dismiss_error": "Dispensar erro", @@ -515,8 +550,11 @@ "display_original_photos": "Exibir fotos originais", "display_original_photos_setting_description": "Prefira exibir a foto original ao visualizar um arquivo em vez de miniaturas quando o arquivo original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", "do_not_show_again": "Não mostrar esta mensagem novamente", + "documentation": "Documentação", "done": "Feito", "download": "Baixar", + "download_include_embedded_motion_videos": "Vídeos inclusos", + "download_include_embedded_motion_videos_description": "Baixar os vídeos inclusos de uma foto em movimento em um arquivo separado", "download_settings": "Baixar", "download_settings_description": "Gerenciar configurações relacionadas a transferência de arquivos", "downloading": "Baixando", @@ -546,10 +584,15 @@ "edit_location": "Editar Localização", "edit_name": "Editar nome", "edit_people": "Editar pessoas", + "edit_tag": "Editar tag", "edit_title": "Editar Título", "edit_user": "Editar usuário", "edited": "Editado", "editor": "Editar", + "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor_close_without_save_title": "Fechar editor?", + "editor_crop_tool_h2_aspect_ratios": "Proporções", + "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", @@ -562,16 +605,16 @@ "error_loading_image": "Erro ao carregar a página", "error_title": "Erro - Algo deu errado", "errors": { - "cannot_navigate_next_asset": "Não é possível navegar para o próximo arquivo", - "cannot_navigate_previous_asset": "Não é possível navegar para o arquivo anterior", - "cant_apply_changes": "Não é possível aplicar modificações", - "cant_change_activity": "Não é possível {enabled, select, true {disable} other {enable}} atividade", - "cant_change_asset_favorite": "Não é possível mudar favorito para o arquivo", - "cant_change_metadata_assets_count": "Não é possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo arquivo", + "cannot_navigate_previous_asset": "Não foi possível navegar para o arquivo anterior", + "cant_apply_changes": "Não foi possível aplicar as alterações", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {habilitar}} a atividade", + "cant_change_asset_favorite": "Não foi possível mudar favorito para o arquivo", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", "cant_get_faces": "Não foi possível obter os rostos", - "cant_get_number_of_comments": "Não é possível obter o número de comentários", - "cant_search_people": "Não é possível procurar pessoas", - "cant_search_places": "Não é possível procurar locais", + "cant_get_number_of_comments": "Não foi possível obter o número de comentários", + "cant_search_people": "Não foi possível procurar pessoas", + "cant_search_places": "Não foi possível procurar locais", "cleared_jobs": "Tarefas eliminadas para: {job}", "error_adding_assets_to_album": "Erro ao adicionar arquivos para o álbum", "error_adding_users_to_album": "Erro ao adicionar usuários para o álbum", @@ -605,11 +648,11 @@ "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", "unable_to_add_remove_archive": "Não é possível {archived, select, true {remove asset from} other {add asset to}} arquivar", - "unable_to_add_remove_favorites": "Não é possível {favorite, select, true {add asset to} other {remove asset from}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {archive} other {unarchive}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar o arquivo aos} other {remover o arquivo dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do usuário no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não é possível alterar o favorito para o arquivo", + "unable_to_change_favorite": "Não foi possível alterar o favorito para o arquivo", "unable_to_change_location": "Não foi possível alterar a localização", "unable_to_change_password": "Não foi possível alterar a senha", "unable_to_change_visibility": "Não foi possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", @@ -630,7 +673,7 @@ "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", "unable_to_delete_user": "Não foi possível deletar o usuário", - "unable_to_download_files": "Não foi possível fazer download dos arquivos", + "unable_to_download_files": "Não foi possível baixar os arquivos", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Não foi possível obter o número de comentários", "unable_to_get_shared_link": "Não foi possível obter link o compartilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", + "unable_to_link_motion_video": "Não foi possível relacionar ao video animado", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", "unable_to_load_asset_activity": "Não foi possível carregar as atividades do arquivo", @@ -648,22 +692,22 @@ "unable_to_log_out_device": "Não foi possível sair do dispositivo", "unable_to_login_with_oauth": "Não foi possível fazer login com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos para {name, select, null {an existing person} other {{name}}}", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir arquivos a {name, select, null {uma pessoa} other {{name}}}", "unable_to_reassign_assets_new_person": "Não foi possível reatribuir arquivos a uma nova pessoa", "unable_to_refresh_user": "Não foi possível atualizar o usuário", "unable_to_remove_album_users": "Não foi possível remover usuários do álbum", "unable_to_remove_api_key": "Não foi possível a Chave de API", "unable_to_remove_assets_from_shared_link": "Não foi possível remover arquivos do link compartilhado", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Não foi possível remover arquivos offline", "unable_to_remove_library": "Não foi possível remover a biblioteca", - "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", "unable_to_remove_partner": "Não foi possível remover parceiro", "unable_to_remove_reaction": "Não foi possível remover a reação", "unable_to_remove_user": "", "unable_to_repair_items": "Não foi possível reparar os itens", "unable_to_reset_password": "Não foi possível resetar a senha", "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar o(s) arquivo(s)", + "unable_to_restore_assets": "Não foi possível restaurar", "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", "unable_to_restore_user": "Não foi possível restaurar usuário", "unable_to_save_album": "Não foi possível salvar o álbum", @@ -679,6 +723,7 @@ "unable_to_submit_job": "Não foi possível enviar a tarefa", "unable_to_trash_asset": "Não foi possível enviar o arquivo para a lixeira", "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_unlink_motion_video": "Não foi possível remover a relação com o video animado", "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", "unable_to_update_album_info": "Não foi possível atualizar as informações do álbum", "unable_to_update_library": "Não foi possível atualizar a biblioteca", @@ -699,6 +744,7 @@ "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", + "explorer": "Explorar", "export": "Exportar", "export_as_json": "Exportar como JSON", "extension": "Extensão", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Foto principal atualizada", "featurecollection": "", + "features": "Funcionalidades", + "features_setting_description": "Gerenciar as funcionalidades da aplicação", "file_name": "Nome do arquivo", "file_name_or_extension": "Nome do arquivo ou extensão", "filename": "Nome do arquivo", @@ -720,12 +768,14 @@ "filter_people": "Filtrar pessoas", "find_them_fast": "Encontre pelo nome em uma pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", + "folders": "Pastas", + "folders_feature_description": "Navegar pelas pastas das fotos e vídeos no sistema de arquivos", "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", "forward": "Para frente", "general": "Geral", "get_help": "Obter Ajuda", "getting_started": "Primeiros passos", - "go_back": "Retornar", + "go_back": "Voltar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", @@ -743,16 +793,16 @@ "host": "Host", "hour": "Hora", "image": "Imagem", - "image_alt_text_date": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} em {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1} e {person2} em {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} com {person1}, {person2}, e {additionalCount, number} outros em {date}", - "image_alt_text_date_place": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} em {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} em {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1} e {person2} em {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo} other {Imagem}} {isVideo, select, true {tirado} other {tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", + "image_alt_text_date": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} em {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1} e {person2} em {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} com {person1}, {person2}, e outras {additionalCount, number} em {date}", + "image_alt_text_date_place": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} em {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} em {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1} e {person2} em {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {additionalCount, number} outros em {date}", "image_alt_text_people": "{count, plural, =1 {com {person1}} =2 {com {person1} e {person2}} =3 {com {person1}, {person2}, e {person3}} other {com {person1}, {person2} e outras {others, number} pessoas}}", "image_alt_text_place": "em {city}, {country}", "image_taken": "{isVideo, select, true {Gravado} other {Fotografado}}", @@ -819,6 +869,7 @@ "license_trial_info_4": "Por favor, Considere adquirir uma licença para apoiar o desenvolvimento contínuo do serviço", "light": "Claro", "like_deleted": "Curtida excluída", + "link_motion_video": "Relacionar video animado", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", "linked_oauth_account": "Conta OAuth Vinculada", @@ -837,6 +888,7 @@ "look": "Estilo", "loop_videos": "Repetir vídeos", "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", + "main_branch_warning": "Você está utilizando a versão de desenvolvimento. É altamente recomendado que utilize a versão estável!", "make": "Marca", "manage_shared_links": "Gerir links partilhados", "manage_sharing_with_partners": "Gerenciar compartilhamento com parceiros", @@ -858,10 +910,10 @@ "menu": "Menu", "merge": "Mesclar", "merge_people": "Mesclar pessoas", - "merge_people_limit": "Só é possível combinar até 5 rostos de uma só vez", + "merge_people_limit": "Só é possível mesclar até 5 pessoas de uma só vez", "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", "merge_people_successfully": "Pessoas mescladas com sucesso", - "merged_people_count": "{count, plural, one {# pessoa foi combinada} other {# pessoas foram combinadas}}", + "merged_people_count": "{count, plural, one {# pessoa foi mesclada} other {# pessoas foram mescladas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Faltando", @@ -906,18 +958,21 @@ "notifications": "Notificações", "notifications_setting_description": "Gerenciar notificações", "oauth": "OAuth", + "official_immich_resources": "Recursos oficiais do Immich", "offline": "Offline", "offline_paths": "Caminhos offline", "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", + "onboarding_privacy_description": "As seguintes funções opcionais dependem de serviços externos e podem ser desabilitadas a qualquer momento nas configurações de administração.", "onboarding_theme_description": "Escolha um tema de cores para sua instância. Você pode alterar isso posteriormente em suas configurações.", "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", "onboarding_welcome_user": "Bem-vindo, {user}", "online": "Online", "only_favorites": "Somente favoritos", "only_refreshes_modified_files": "Somente atualize arquivos modificados", + "open_in_map_view": "Mostrar no mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", "open_the_search_filters": "Abre os filtros de pesquisa", "options": "Opções", @@ -952,6 +1007,7 @@ "pending": "Pendente", "people": "Pessoas", "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", + "people_feature_description": "Navegar por fotos e vídeos agrupados por pessoas", "people_sidebar_description": "Exibe o link Pessoas na barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Aviso para deletar permanentemente", @@ -984,6 +1040,7 @@ "previous_memory": "Memória anterior", "previous_or_next_photo": "Foto anterior ou próxima", "primary": "Primário", + "privacy": "Privacidade", "profile_image_of_user": "Imagem do perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", @@ -1007,8 +1064,8 @@ "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", - "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço, e temos engenheiros dedicados trabalhando nele para torná-lo o melhor possível. Nossa missão é que programas de código aberto e as práticas empresariais éticas se tornem uma fonte de receita sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar bloqueios de pagamento, esta compra não lhe concederá recursos adicionais no Immich. Contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", + "purchase_panel_info_1": "Construir o Immich leva muito tempo e esforço. Temos engenheiros trabalhando em tempo integral para torná-lo o melhor possível. Nossa missão é fazer com que programas de código aberto e práticas empresariais éticas se tornem uma fonte de renda sustentável para os desenvolvedores e também criar um ecossistema que respeite a privacidade, oferecendo alternativas reais aos serviços de nuvem exploratórios.", + "purchase_panel_info_2": "Como estamos comprometidos em não adicionar funções bloqueadas por compras, esta compra não lhe concederá nenhum recurso adicional no Immich. Nós contamos com usuários como você para apoiar o desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoiar o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por usuário", @@ -1021,22 +1078,28 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "A chave do produto para servidor é gerenciada pelo administrador", "range": "", + "rating": "Estrelas", + "rating_clear": "Limpar classificação", + "rating_count": "{count, plural, one {# estrela} other {# estrelas}}", + "rating_description": "Exibir o EXIF de classificação no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {an existing person} other {{name}}}", + "reassigned_assets_to_existing_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a {name, select, null {uma pessoa} other {{name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# arquivo reatribuído} other {# arquivos reatribuídos}} a uma nova pessoa", "reassing_hint": "Atribuir arquivos selecionados a uma pessoa existente", "recent": "Recente", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", "refresh_encoded_videos": "Atualizar vídeos codificados", + "refresh_faces": "Atualizar rostos", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", "refreshes_every_file": "Atualiza todos arquivos", "refreshing_encoded_video": "Atualizando vídeo codificado", + "refreshing_faces": "Atualizando rostos", "refreshing_metadata": "Atualizando metadados", "regenerating_thumbnails": "Regenerando miniaturas", "remove": "Remover", @@ -1044,15 +1107,16 @@ "remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", "remove_assets_title": "Remover arquivos?", "remove_custom_date_range": "Remover intervalo de datas personalizado", + "remove_deleted_assets": "Remover arquivos offline", "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", "remove_from_shared_link": "Remover do link compartilhado", - "remove_offline_files": "Remover arquivos offline", "remove_user": "Remover usuário", "removed_api_key": "Removido a Chave de API: {name}", "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_tagged_assets": "Tag removida de {count, plural, one {# arquivo} other {# arquivos}}", "rename": "Renomear", "repair": "Reparar", "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", @@ -1084,6 +1148,7 @@ "say_something": "Diga algo", "scan_all_libraries": "Escanear Todas Bibliotecas", "scan_all_library_files": "Re-escanear todos arquivos da biblioteca", + "scan_library": "Analisar", "scan_new_library_files": "Escanear novos arquivos na biblioteca", "scan_settings": "Opções de escanear", "scanning_for_album": "Escaneando por álbum...", @@ -1099,9 +1164,12 @@ "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_settings": "Configurações de pesquisa", "search_state": "Pesquisar estado...", + "search_tags": "Procurar tags...", "search_timezone": "Pesquisar fuso horário...", "search_type": "Pesquisar tipo", "search_your_photos": "Pesquisar fotos", @@ -1125,8 +1193,8 @@ "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", - "server_offline": "Servidor Fora do Ar", - "server_online": "Servidor no Ar", + "server_offline": "Servidor Indisponível", + "server_online": "Servidor Disponível", "server_stats": "Status do servidor", "server_version": "Versão do servidor", "set": "Definir", @@ -1143,14 +1211,16 @@ "shared_by_user": "Compartilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opções do link compartilhado", "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, one {# foto e vídeo compartilhados.} other {# fotos e vídeos compartilhados.}}", + "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", "sharing": "Compartilhar", "sharing_enter_password": "Digite a senha para visualizar esta página.", "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", "shift_to_permanent_delete": "pressione ⇧ para excluir permanentemente o arquivo", "show_album_options": "Exibir opções do álbum", + "show_albums": "Exibir álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", "show_file_location": "Exibir local do arquivo", @@ -1165,13 +1235,18 @@ "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", + "show_slideshow_transition": "Usar transições no modo de apresentação", "show_supporter_badge": "Insígnia de Contribuidor", - "show_supporter_badge_description": "Mostrar uma insígnia de contribuidor", + "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", "shuffle": "Aleatório", + "sidebar": "Barra lateral", + "sidebar_display_description": "Exibir um link para visualizar na barra lateral", "sign_out": "Sair", "sign_up": "Registrar", "size": "Tamanho", "skip_to_content": "Pular para o conteúdo", + "skip_to_folders": "Ir para pastas", + "skip_to_tags": "Ir para as tags", "slideshow": "Apresentação", "slideshow_settings": "Opções de apresentação", "sort_albums_by": "Ordenar álbuns por...", @@ -1183,6 +1258,8 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", + "stack_duplicates": "Empilhar duplicados", + "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", @@ -1200,22 +1277,35 @@ "submit": "Enviar", "suggestions": "Sugestões", "sunrise_on_the_beach": "Nascer do sol na praia", + "support": "Ajuda", + "support_and_feedback": "Ajuda & Feedback", + "support_third_party_description": "Sua instalação do Immich é fornecida por terceiros. É possível que problemas sejam causados por eles, por isso, se tiver problemas, procure primeiro ajuda com eles utilizando os links abaixo.", "swap_merge_direction": "Alternar direção da mesclagem", "sync": "Sincronizar", + "tag": "Tag", + "tag_assets": "Marcar com tag", + "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_updated": "Tag foi atualizada: {tag}", + "tagged_assets": "{count, plural, one {# arquivo marcado} other {# arquivos marcados}} com a tag", + "tags": "Tags", "template": "Modelo", "theme": "Tema", "theme_selection": "Selecionar tema", "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", - "they_will_be_merged_together": "Eles serão combinados", + "they_will_be_merged_together": "Eles serão mesclados", + "third_party_resources": "Recursos de terceiros", "time_based_memories": "Memórias baseada no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", "to_change_password": "Alterar senha", "to_favorite": "Favorito", "to_login": "Iniciar sessão", + "to_parent": "Voltar um nível acima", "to_trash": "Mover para a lixeira", "toggle_settings": "Alternar configurações", - "toggle_theme": "Alternar tema", + "toggle_theme": "Alternar tema escuro", "toggle_visibility": "Alternar visibilidade", "total_usage": "Utilização total", "trash": "Lixeira", @@ -1234,13 +1324,15 @@ "unknown_album": "", "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", "unnamed_album": "Álbum sem nome", + "unnamed_album_delete_confirmation": "Tem certeza que deseja excluir este álbum?", "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não salva", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Deselecionar todas as duplicatas", + "unselect_all_duplicates": "Desselecionar todas as duplicatas", "unstack": "Desempilhar", "unstacked_assets_count": "{count, plural, one {# arquivo não empilhado} other {# arquivos não empilhados}}", "untracked_files": "Arquivos não monitorados", @@ -1250,7 +1342,7 @@ "upload": "Carregar", "upload_concurrency": "Carregar simultâneo", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.", - "upload_progress": "Restando {remaining, number} - Processando(a)(s) {processed, number}/{total, number}", + "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados", "upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", @@ -1258,13 +1350,13 @@ "upload_success": "Carregado com sucesso, atualize a página para ver os novos arquivos.", "url": "URL", "usage": "Uso", - "use_custom_date_range": "Usar intervalo de datas personalizado invés", + "use_custom_date_range": "Usar intervalo de datas personalizado", "user": "Usuário", "user_id": "ID do usuário", "user_license_settings": "Licença", "user_license_settings_description": "Gerenciar sua licença", - "user_liked": "{user} curtiu {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", - "user_purchase_settings": "Compra", + "user_liked": "{user} curtiu {type, select, photo {a foto} video {o vídeo} asset {o arquivo} other {isso}}", + "user_purchase_settings": "Comprar", "user_purchase_settings_description": "Gerenciar sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do usuário", @@ -1275,22 +1367,25 @@ "variables": "Variáveis", "version": "Versão", "version_announcement_closing": "De seu amigo, Alex", - "version_announcement_message": "Olá, amigo, há uma nova versão do aplicativo disponível. Por favor, visite com calma a página notas da versão e certifique-se de que a configuração do docker-compose.yml, e do .env estejam atualizadas para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo.", + "version_announcement_message": "Olá amigo! Uma nova versão do aplicativo está disponível. Para evitar configurações incorretas, por favor verifique com calma a página de notas da versão e certifique-se que os arquivos docker-compose.yml e .env estão configurados corretamente, principalmente se você usa o WatchTower ou qualquer outro mecanismo que faça atualizações automáticas.", + "version_history": "Histórico de versões", + "version_history_item": "Instalado {version} em {date}", "video": "Vídeo", "video_hover_setting": "Reproduzir miniatura do vídeo ao passar o mouse", "video_hover_setting_description": "Reproduzir a miniatura do vídeo ao passar o mouse sobre o item. Mesmo quando desativado, a reprodução pode ser iniciada ao passar o mouse sobre o ícone de reprodução.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", - "view_album": "Exibir álbum", + "view_album": "Ver álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos usuários", + "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", "view_next_asset": "Ver próximo arquivo", "view_previous_asset": "Ver arquivo anterior", "view_stack": "Exibir Pilha", "viewer": "Visualizar", - "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", + "visibility_changed": "A visibilidade de {count, plural, one {# pessoa foi alterada} other {# pessoas foram alteradas}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", diff --git a/i18n/ro.json b/i18n/ro.json new file mode 100644 index 0000000000..4078f656b9 --- /dev/null +++ b/i18n/ro.json @@ -0,0 +1,1095 @@ +{ + "about": "Despre", + "account": "Cont", + "account_settings": "Setări Cont", + "acknowledge": "Văzut", + "action": "Acţiune", + "actions": "Acţiuni", + "active": "Activ", + "activity": "Activitate", + "activity_changed": "Activitatea este {enabled, select, true {activată} other {dezactivată}}", + "add": "Adaugă", + "add_a_description": "Adaugă o descriere", + "add_a_location": "Adaugă locație", + "add_a_name": "Adaugă un nume", + "add_a_title": "Adaugă un titlu", + "add_exclusion_pattern": "Adăugă un model de excludere", + "add_import_path": "Adaugă o cale de import", + "add_location": "Adaugă o locație", + "add_more_users": "Adaugă mai mulți utilizatori", + "add_partner": "Adaugă partener", + "add_path": "Adaugă o cale", + "add_photos": "Adaugă fotografii", + "add_to": "Adaugă la...", + "add_to_album": "Adaugă în album", + "add_to_shared_album": "Adaugă la album partajat", + "added_to_archive": "Adăugat la arhivă", + "added_to_favorites": "Adaugă la favorite", + "added_to_favorites_count": "Adăugat {count, number} la favorite", + "admin": { + "add_exclusion_pattern_description": "Adăugați modele de excludere. Globing folosind *, ** și ? este suportat. Pentru a ignora toate fișierele din orice director numit „Raw”, utilizați „**/Raw/**”. Pentru a ignora toate fișierele care se termină în „.tif”, utilizați „**/*.tif”. Pentru a ignora o cale absolută, utilizați „/path/to/ignore/**”.", + "asset_offline_description": "Acest material din biblioteca externă nu se mai găsește pe disc și a fost mutat în coșul de gunoi. Dacă fișierul a fost mutat în bibliotecă, verificați cronologia pentru noul material corespunzător. Pentru a restabili acest material, asigurați-vă că calea fișierului de mai jos poate fi accesată de Immich și scanați biblioteca.", + "authentication_settings": "Setări de autentificare", + "authentication_settings_description": "Gestionează parola, OAuth și alte setări de autentificare", + "authentication_settings_disable_all": "Ești sigur că vrei sa dezactivezi toate metodele de autentificare? Autentificarea va fi complet dezactivată.", + "authentication_settings_reenable": "Pentru a reactiva, folosește Comandă Server.", + "background_task_job": "Activități de fundal", + "check_all": "Bifează toate", + "cleared_jobs": "Activități eliminate pentru: {job}", + "config_set_by_file": "Configurația este setată în prezent de un fișier de configurare", + "confirm_delete_library": "Sigur doriți să ștergeți biblioteca {library}?", + "confirm_delete_library_assets": "Sigur doriți să ștergeți această bibliotecă? Aceasta va șterge {count, plural, one {# contained asset} other {all # contained assets}} din Immich și nu poate fi anulată. Fișierele vor rămâne pe disc.", + "confirm_email_below": "Pentru a confirma, tastați „{email}” mai jos", + "confirm_reprocess_all_faces": "Sigur doriți să reprocesați toate fețele? Acest lucru va șterge și persoanele cu nume.", + "confirm_user_password_reset": "Sigur doriți să resetați parola utilizatorului {user}?", + "create_job": "Creează sarcină", + "crontab_guru": "", + "disable_login": "Dezactivați autentificarea", + "disabled": "", + "duplicate_detection_job_description": "Rulați învățarea automată pe materiale pentru a detecta imagini similare. Se bazează pe Căutare Inteligentă", + "exclusion_pattern_description": "Modelele de excludere vă permit să ignorați fișierele și folderele atunci când vă scanați biblioteca. Acest lucru este util dacă aveți foldere care conțin fișiere pe care nu doriți să le importați, cum ar fi fișierele RAW.", + "external_library_created_at": "Bibliotecă externă (creată pe {date})", + "external_library_management": "Managementul Bibliotecii Externe", + "face_detection": "Detecție facială", + "face_detection_description": "Detectează fețele din fișiere folosind învățare automată. Pentru videoclipuri, este luată în considerare doar miniatura. „Reînprospătează” (re)procesează toate fișierele. „Resetează” adaugă în coadă fișierele care nu au fost încă procesate. Fețele detectate vor fi puse în coadă pentru recunoașterea facială după finalizarea detectării feței, grupându-le în persoane existente sau noi.", + "facial_recognition_job_description": "Grupați fețele detectate în persoane. Acest pas rulează după ce Detectarea Feței este finalizată. „Resetează” (re)grupează toate fețele. „Lipsă” adaugă în coadă fețe care nu au o persoană desemnată.", + "failed_job_command": "Comanda {command} a eșuat pentru jobul: {job}", + "force_delete_user_warning": "AVERTISMENT: Acest lucru va elimina imediat utilizatorul și toate activele sale. Acest lucru nu poate fi anulat și fișierele nu pot fi recuperate.", + "forcing_refresh_library_files": "Forțarea reîmprospătării tuturor fișierelor din bibliotecă", + "image_format": "Formateaza", + "image_format_description": "WebP produce fișiere mai mici decât JPEG, dar este mai lent de codat.", + "image_prefer_embedded_preview": "Preferați previzualizarea încorporată", + "image_prefer_embedded_preview_setting_description": "Folosiți previzualizările încorporate în fotografiile RAW ca intrare pentru procesarea imaginii, atunci când sunt disponibile. Acest lucru poate produce culori mai precise pentru unele imagini, dar calitatea previzualizării depinde de cameră și imaginea poate avea mai multe artefacte de compresie.", + "image_prefer_wide_gamut": "Preferă o gamă largă", + "image_prefer_wide_gamut_setting_description": "Utilizați Display P3 pentru miniaturi. Acest lucru păstrează mai bine vibrația imaginilor cu spații de culoare largi, dar imaginile pot apărea diferit pe dispozitivele cu o versiune mai veche de browser. Imaginile sRGB sunt păstrate ca sRGB pentru a evita schimbările de culoare.", + "image_preview_description": "Imagine de dimensiune medie cu metadate eliminate, utilizată la vizualizarea unui singur element și pentru învățarea automată", + "image_preview_format": "Format de previzualizare", + "image_preview_quality_description": "Calitatea previzualizării de la 1 la 100. O valoare mai mare oferă o calitate mai bună, dar produce fișiere mai mari și poate reduce receptivitatea aplicației. Setarea unei valori scăzute poate afecta calitatea învățării automate.", + "image_preview_resolution": "Previzualizare rezoluție", + "image_preview_resolution_description": "Folosit la vizualizarea unei singure fotografii și pentru învățarea automată. Rezoluțiile mai mari pot păstra mai multe detalii, dar codarea durează mai mult, au dimensiuni mai mari ale fișierelor și pot reduce capacitatea de răspuns a aplicației.", + "image_preview_title": "Previzualizeaza setarile", + "image_quality": "Calitate", + "image_quality_description": "Calitatea imaginii de la 1 la 100. Număr mai mare este mai bun pentru calitate dar produce fișiere mai mari, această opțiune afectează imaginile Preview și Thumbnail.", + "image_resolution": "Rezolutie", + "image_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru a fi codificate, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", + "image_settings": "Setările imaginii", + "image_settings_description": "Gestionează calitatea și rezoluția imaginilor generate", + "image_thumbnail_description": "Miniatură mică cu metadate eliminate, utilizată la vizualizarea grupurilor de fotografii, cum ar fi în cronologia principală", + "image_thumbnail_format": "Format imagini miniatură", + "image_thumbnail_quality_description": "Calitatea miniaturii de la 1 la 100. O valoare mai mare oferă o calitate mai bună, dar produce fișiere mai mari și poate reduce receptivitatea aplicației.", + "image_thumbnail_resolution": "Rezoluție imagini miniatură", + "image_thumbnail_resolution_description": "Folosit la vizualizarea unor grupuri de fotografii (cronologie principală, vizualizare album etc.). Rezoluțiile mai mari pot păstra mai multe detalii, dar codarea durează mai mult, au dimensiuni mai mari ale fișierelor și pot reduce capacitatea de răspuns a aplicației.", + "image_thumbnail_title": "Setari miniaturi", + "job_concurrency": "concurență {job}", + "job_created": "Sarcină creată", + "job_not_concurrency_safe": "Acest job nu este sigur pentru a rula în concurență.", + "job_settings": "Setări sarcină", + "job_settings_description": "Administrează concurența sarcinilor", + "job_status": "Starea sarcinii", + "jobs_delayed": "{jobCount, plural, other {# delayed}}", + "jobs_failed": "{jobCount, plural, other {# eșuat}}", + "library_created": "Librărie creată:{library}", + "library_cron_expression": "Expresie Cron", + "library_cron_expression_description": "Setează intervalul de scanare folosind formatul cron. Pentru mai multe informații, vă rugăm referiți-vă la pentru exemplu: Crontab Guru", + "library_cron_expression_presets": "presetări expresie cron", + "library_deleted": "Bibliotecă ștearsă", + "library_import_path_description": "Specificați un folder pentru a îl importa. Acest folder, inclusiv sub-folderele, vor fi scanate pentru imagini și videoclipuri.", + "library_scanning": "Scanare Periodică", + "library_scanning_description": "Configurează scanarea periodică pentru bibliotecă", + "library_scanning_enable_description": "Activează scanarea periodică pentru bibliotecă", + "library_settings": "Bibliotecă Externă", + "library_settings_description": "Administrează setările pentru biblioteci externe", + "library_tasks_description": "Efectuează sarcini asupra bibliotecii", + "library_watching_enable_description": "Urmărește bibliotecile externe pentru schimbări ale fișierelor", + "library_watching_settings": "Urmărirea bibliotecii (EXPERIMENTAL)", + "library_watching_settings_description": "Urmărește automat fișierele schimbate", + "logging_enable_description": "Activează înregistrarea log-urilor", + "logging_level_description": "Dacă setarea este activată, înregistrează evenimentele cu nivelul.", + "logging_settings": "Înregistrare", + "machine_learning_clip_model": "Model CLIP", + "machine_learning_clip_model_description": "Numele unui model CLIP listat aici. Rețineți că trebuie să rulați din nou funcția „Smart Search” pentru toate imaginile la schimbarea unui model.", + "machine_learning_duplicate_detection": "Detectarea duplicatelor", + "machine_learning_duplicate_detection_enabled": "Activează detectarea duplicatelor", + "machine_learning_duplicate_detection_enabled_description": "Dacă este dezactivată, activele identice vor fi în continuare de-duplicate.", + "machine_learning_duplicate_detection_setting_description": "Utilizați încorporările CLIP pentru a găsi dubluri probabile", + "machine_learning_enabled": "Activează algoritmii de învățare automată", + "machine_learning_enabled_description": "Dacă este dezactivat, toate funcțiile ML vor fi dezactivate indiferent de setările de mai jos.", + "machine_learning_facial_recognition": "Recunoaștere Facială", + "machine_learning_facial_recognition_description": "Detectează, recunoaște și grupează fețe din imagini", + "machine_learning_facial_recognition_model": "Model de recunoaștere facială", + "machine_learning_facial_recognition_model_description": "Modelele sunt aranjate descrescător după mărime. Modelele mai mari sunt lente și folosesc multă memorie, dar produc rezultate mai bune. Rețineți că va trebui să rulați din nou Recunoașterea Facială pentru toate imaginile dacă schimbați modelul.", + "machine_learning_facial_recognition_setting": "Activează Recunoașterea Facială", + "machine_learning_facial_recognition_setting_description": "Dacă este dezactivată, imaginile nu vor fi codificate pentru recunoașterea facială și nu vor popula secțiunea Persoane din pagina Explorare.", + "machine_learning_max_detection_distance": "Distanța maximă pentru recunoaștere", + "machine_learning_max_detection_distance_description": "Distanța maximă dintre două imagini pentru a le considera duplicate, variind între 0,001-0,1. Valorile mai mari vor detecta mai multe duplicate, dar pot duce la rezultate fals pozitive.", + "machine_learning_max_recognition_distance": "Distanța maximă de recunoaștere", + "machine_learning_max_recognition_distance_description": "Distanța maximă dintre două fețe pentru a fi considerate aceeași persoană, variind între 0-2. Reducerea acestui prag poate împiedica etichetarea a două persoane ca fiind aceeași persoană, în timp ce creșterea lui poate împiedica etichetarea aceleiași persoane ca fiind două persoane diferite. Rețineți că este mai ușor să unificați două persoane decât să împărțiți o persoană în două, deci, dacă este posibil, alegeți un prag mai mic.", + "machine_learning_min_detection_score": "Scor minim de detecție", + "machine_learning_min_detection_score_description": "Scorul minim de încredere pentru ca o față să fie detectată de la 0 la 1. Valorile mai mici vor detecta mai multe fețe, dar pot duce la fals pozitive.", + "machine_learning_min_recognized_faces": "Fețe minime recunoscute", + "machine_learning_min_recognized_faces_description": "Numărul minim de fețe recunoscute pentru ca o persoană să fie creată. Creșterea acestui număr face ca recunoașterea facială să fie mai precisă, cu prețul creșterii șanselor ca o față să nu fie atribuită unei persoane.", + "machine_learning_settings": "Setări machine learning", + "machine_learning_settings_description": "Gestionați caracteristicile și setările de învățare automată", + "machine_learning_smart_search": "Căutare inteligentă", + "machine_learning_smart_search_description": "Căutarea semantică a imaginilor utilizând încorporările CLIP", + "machine_learning_smart_search_enabled": "Activați căutarea inteligentă", + "machine_learning_smart_search_enabled_description": "Dacă este dezactivată, imaginile nu vor fi codificate pentru căutarea inteligentă.", + "machine_learning_url_description": "Adresa URL a serverului de învățare automată", + "manage_concurrency": "Gestionarea simultaneității", + "manage_log_settings": "Administrați setările jurnalului", + "map_dark_style": "Mod întunecat", + "map_enable_description": "Activare hartă", + "map_gps_settings": "Setări Hartă & GPS", + "map_gps_settings_description": "Gestionare setări Hartă & GPS (localizare inversă)", + "map_implications": "Caracteristica hărții se bazează pe un serviciu extern de planșe (tiles.immich.cloud)", + "map_light_style": "Mod deschis", + "map_manage_reverse_geocoding_settings": "Gestionare setări Localizare Inversă", + "map_reverse_geocoding": "Localizare Inversă", + "map_reverse_geocoding_enable_description": "Activați geocodarea inversă", + "map_reverse_geocoding_settings": "Setări geocodare inversă", + "map_settings": "Hartă", + "map_settings_description": "Gestionare setări hartă", + "map_style_description": "URL-ul style.json către o temă pentru hartă", + "metadata_extraction_job": "Extragere metadata", + "metadata_extraction_job_description": "Extragere informații metadata din fiecare fișier cum ar fi localizare GPS, fețe și rezoluție,", + "metadata_faces_import_setting": "Activare import fețe", + "metadata_faces_import_setting_description": "Importă fețe din datele EXIF ale imaginii și din fișiere tip \"sidecar\"", + "metadata_settings": "Setări Metadata", + "metadata_settings_description": "Gestionează setările metadata", + "migration_job": "Migrare", + "migration_job_description": "Migrați miniaturile pentru elemente și fețe la cea mai recentă structură de foldere", + "no_paths_added": "Nicio cale adăugată", + "no_pattern_added": "Niciun tipar adăugat", + "note_apply_storage_label_previous_assets": "Notă: Pentru a aplica Eticheta de Stocare la elementele încărcate anterior, executați", + "note_cannot_be_changed_later": "NOTĂ: Nu se va mai putea modifica ulterior!", + "note_unlimited_quota": "Notă: Introduceți 0 pentru cotă nelimitată", + "notification_email_from_address": "De la adresa", + "notification_email_from_address_description": "Adresa expeditorului, spre exemplu: „Immich Photo Server ”", + "notification_email_host_description": "Adresa serverului de email (e.g. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ingnoră erorile de certificat", + "notification_email_ignore_certificate_errors_description": "Ignoră erorile de validare a certificatului TLS (nerecomandat)", + "notification_email_password_description": "Parola utilizată pentru autentificarea în serverul de email", + "notification_email_port_description": "Portul utilizat de serverul de email (ex. 25, 465 sau 587)", + "notification_email_sent_test_email_button": "Trimite un email de test și salvează configurația", + "notification_email_setting_description": "Setări pentru trimiterea de notificări pe email", + "notification_email_test_email": "Trimitere email de test", + "notification_email_test_email_failed": "Eroare la trimiterea emailului de test, verificați setările", + "notification_email_test_email_sent": "Un email de test a fost trimis la adresa {email}. Vă rugăm să verificați.", + "notification_email_username_description": "Numele de utilizator pentru autentificarea pe serverul de email", + "notification_enable_email_notifications": "Activare notificări pe email", + "notification_settings": "Setări Notificare", + "notification_settings_description": "Gestionează setările pentru notificări, inclusiv adresa de email", + "oauth_auto_launch": "Pornire automată", + "oauth_auto_launch_description": "Lansează automat autorizarea OAuth la accesarea paginii de login", + "oauth_auto_register": "Auto înregistrare", + "oauth_auto_register_description": "Înregistrează automat utilizatori noi după autentificarea cu OAuth", + "oauth_button_text": "Text buton", + "oauth_client_id": "ID Client", + "oauth_client_secret": "Secret Client", + "oauth_enable_description": "Autentifică-te cu OAuth", + "oauth_issuer_url": "Emitentul URL", + "oauth_mobile_redirect_uri": "URI de redirecționare mobilă", + "oauth_mobile_redirect_uri_override": "Înlocuire URI de redirecționare mobilă", + "oauth_mobile_redirect_uri_override_description": "Activați atunci când furnizorul OAuth nu permite un URI mobil, precum '{callback}'", + "oauth_profile_signing_algorithm": "Algoritm de semnare a profilului", + "oauth_profile_signing_algorithm_description": "Algoritm folosit pentru a semna profilul utilizatorului.", + "oauth_scope": "Domeniul de aplicare", + "oauth_settings": "OAuth", + "oauth_settings_description": "Gestionați setările de conectare OAuth", + "oauth_settings_more_details": "Pentru mai multe detalii despre aceastǎ funcționalitate, verificǎ documentația.", + "oauth_signing_algorithm": "Algoritm de semnare", + "oauth_storage_label_claim": "Revendicare eticheta de stocare", + "oauth_storage_label_claim_description": "Setați automat eticheta de stocare a utilizatorului la valoarea acestei revendicări.", + "oauth_storage_quota_claim": "Revendicare cotă de stocare", + "oauth_storage_quota_claim_description": "Setează automat cota de stocare a utilizatorului la valoarea acestei cereri.", + "oauth_storage_quota_default": "Cota implicită de stocare (GiB)", + "oauth_storage_quota_default_description": "Cota în GiB ce urmează a fi utilizată atunci când nu este furnizată nicio solicitare (introduceți 0 pentru o cotă nelimitată).", + "offline_paths": "Cǎi invalide", + "offline_paths_description": "Acestea pot fi rezultate în urma ștergerii manuale a fișierelor ce nu fac parte dintr-o bibliotecǎ externǎ.", + "password_enable_description": "Autentificare cu email și parolǎ", + "password_settings": "Autentificare cu parolǎ", + "password_settings_description": "Gestioneazǎ setǎrile de autentificare cu parola", + "paths_validated_successfully": "Toate cǎile au fost validate cu succes", + "person_cleanup_job": "Ștergere persoane", + "quota_size_gib": "Spațiu de stocare alocat (GiB)", + "refreshing_all_libraries": "Bibliotecile sunt în curs de reîmprospǎtare", + "registration": "Înregistrare administratori", + "registration_description": "Deoarece sunteți primul utilizator de pe sistem, veți fi desemnat ca administrator și sunteți responsabil pentru sarcinile administrative, iar utilizatorii suplimentari vor fi creați de dumneavoastra.", + "removing_deleted_files": "Eliminarea fișierelor offline", + "repair_all": "Reparǎ toate", + "repair_matched_items": "{count, plural, one {Potrivit # obiect} other {Potrivite # obiecte}}", + "repaired_items": "{count, plural, one {Reparat # obiect} other {Reparate # obiecte}}", + "require_password_change_on_login": "Obligǎ utilizatorul sǎ își schimbe parola la prima autentificare", + "reset_settings_to_default": "Reseteazǎ setǎrile la valorile implicite", + "reset_settings_to_recent_saved": "Reseteazǎ setǎrile la valorile salvate recent", + "scanning_library": "Se scanează biblioteca", + "scanning_library_for_changed_files": "Se scaneazǎ biblioteca pentru fișiere modificate", + "scanning_library_for_new_files": "Se scaneazǎ biblioteca pentru fișiere noi", + "search_jobs": "Caută în procesări...", + "send_welcome_email": "Trimite email de bun-venit", + "server_external_domain_settings": "Domeniu extern", + "server_external_domain_settings_description": "Domeniu pentru distribuire publicǎ a scurtǎturilor, incluzând http(s)://", + "server_settings": "Setǎri server", + "server_settings_description": "Gestioneazǎ setǎrile serverului", + "server_welcome_message": "Mesaj de bun-venit", + "server_welcome_message_description": "Un mesaj ce este afișat pe pagina de autentificare.", + "sidecar_job": "Metadate Sidecar", + "sidecar_job_description": "Descoperirea sau sincronizarea metadatelor sidecar din sistemul de fișiere", + "slideshow_duration_description": "Numǎrul de secunde pentru afișarea fiecǎrei imagini", + "smart_search_job_description": "Rulați machine learning pe active pentru a sprijini căutarea inteligentă", + "storage_template_date_time_description": "Momentul creării activului este utilizat pentru informațiile privind data și ora", + "storage_template_date_time_sample": "Eșantion de timp {date}", + "storage_template_enable_description": "Activați motorul de șabloane de stocare", + "storage_template_hash_verification_enabled": "Verificarea hash este activată", + "storage_template_hash_verification_enabled_description": "Activează verificarea hash, nu o dezactivați decât dacă sunteți sigur de implicații", + "storage_template_migration": "Migrarea șablonului de stocare", + "storage_template_migration_description": "Aplicați {template} actual la elementele încărcate anterior", + "storage_template_migration_info": "Modificările de șablon se vor aplica numai materialelor noi. Pentru a aplica retroactiv șablonul la materialele încărcate anterior, rulați {job}.", + "storage_template_migration_job": "Activitate migrare template stocare", + "storage_template_more_details": "Pentru mai multe detalii despre aceasta caracteristică, accesați Șablon stocare si implicațiile", + "storage_template_onboarding_description": "Atunci când este activată, această caracteristică va organiza automat fișierele pe baza unui șablon definit de utilizator. Din cauza unor probleme de stabilitate, aceasta caracteristică este dezactivată implicit. Pentru mai multe informații, te rog sa consulți documentația.", + "storage_template_path_length": "Limita de lungime pentru calea aproximativă: {length, number}/{limit, number}", + "storage_template_settings": "Șablon stocare", + "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", + "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", + "system_settings": "Setǎri de sistem", + "tag_cleanup_job": "Curățare etichete", + "theme_custom_css_settings": "CSS personalizat", + "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", + "theme_settings": "Setări temă", + "theme_settings_description": "Gestionează personalizarea interfeței web Immich", + "these_files_matched_by_checksum": "Aceste fișiere sunt comparate folosind sumele de control", + "thumbnail_generation_job": "Gerează miniaturi", + "thumbnail_generation_job_description": "Generează miniaturi mari, mici și estompate pentru fiecare resursă, precum și miniaturi pentru fiecare persoană", + "transcode_policy_description": "", + "transcoding_acceleration_api": "API de accelerare", + "transcoding_acceleration_api_description": "API-ul care va interacționa cu dispozitivul tău pentru a accelera transcodarea. Această setare este 'best effort': va reveni la transcodarea software în caz de eșec. VP9 poate funcționa sau nu, în funcție de hardware-ul tău.", + "transcoding_acceleration_nvenc": "NVENC (necesitǎ GPU NVIDIA)", + "transcoding_acceleration_qsv": "Quick Sync (necesitǎ CPU Intel de generația a 7-a sau mai mare)", + "transcoding_acceleration_rkmpp": "RKMPP (doar pe SOC-uri Rockchip)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Codec-uri audio acceptate", + "transcoding_accepted_audio_codecs_description": "Selectează care codec-uri audio nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_accepted_containers": "Containere acceptate", + "transcoding_accepted_containers_description": "Selectează formatele de containere care nu trebuie să fie remuxate în MP4. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_accepted_video_codecs": "Codec-uri video acceptate", + "transcoding_accepted_video_codecs_description": "Selectează codec-urile video care nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_advanced_options_description": "Opțiuni pe care majoritatea utilizatorilor nu ar trebui să fie necesar să le schimbe", + "transcoding_audio_codec": "Codec audio", + "transcoding_audio_codec_description": "Opus este opțiunea cu cea mai bună calitate, dar are o compatibilitate mai scăzută cu dispozitivele sau software-ul mai vechi.", + "transcoding_bitrate_description": "Videoclipuri cu un bitrate mai mare decât maximul acceptat sau care nu sunt într-un format acceptat", + "transcoding_codecs_learn_more": "Pentru a afla mai multe despre terminologia folosită aici, consultă documentația FFmpeg pentru codec-ul H.264, codec-ul HEVC și codec-ul VP9.", + "transcoding_constant_quality_mode": "Mod de calitate constantă", + "transcoding_constant_quality_mode_description": "ICQ este mai bun decât CQP, dar unele dispozitive de accelerare hardware nu suportă acest mod. Setarea acestei opțiuni va prefera modul specificat atunci când folosești codificarea bazată pe calitate. Ignorat de NVENC deoarece nu suportă ICQ.", + "transcoding_constant_rate_factor": "Factor de rată constantă (-crf)", + "transcoding_constant_rate_factor_description": "Nivelul de calitate al videoclipului. Valorile tipice sunt 23 pentru H.264, 28 pentru HEVC, 31 pentru VP9 și 35 pentru AV1. Cu cât valoarea este mai mică, cu atât calitatea este mai bună, dar se generează fișiere mai mari.", + "transcoding_disabled_description": "Nu transcodifică niciun videoclip; acest lucru poate afecta redarea pe anumite dispozitive", + "transcoding_hardware_acceleration": "Accelerare Hardware", + "transcoding_hardware_acceleration_description": "Experimental; mult mai rapid, dar va avea o calitate mai scăzută la același bitrate", + "transcoding_hardware_decoding": "Decodare hardware", + "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", + "transcoding_hevc_codec": "codec HEVC", + "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": "Bitrate 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_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", + "transcoding_preferred_hardware_device": "Dispozitiv hardware preferat", + "transcoding_preferred_hardware_device_description": "Se aplică doar la VAAPI și QSV. Setează nodul DRI utilizat pentru transcodarea hardware.", + "transcoding_preset_preset": "Presetare (-preset)", + "transcoding_preset_preset_description": "Viteza de compresie. Presetările mai lente produc fișiere mai mici și îmbunătățesc calitatea atunci când vizezi o anumită rată de biți. VP9 ignoră vitezele de compresie mai mari decât 'mai rapid'.", + "transcoding_reference_frames": "Cadre de referință", + "transcoding_reference_frames_description": "Numărul de cadre de referință atunci când se comprimă un cadru dat. Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. 0 setează această valoare automat.", + "transcoding_required_description": "Numai videoclipuri care nu sunt într-un format acceptat", + "transcoding_settings": "Setări de transcodare video", + "transcoding_settings_description": "Gestionează rezoluția și informațiile de codare ale fișierelor video", + "transcoding_target_resolution": "Rezoluția țintă", + "transcoding_target_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru codare, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", + "transcoding_temporal_aq": "AQ temporal", + "transcoding_temporal_aq_description": "Se aplică doar la NVENC. Îmbunătățește calitatea scenelor cu detalii mari și mișcare redusă. Poate să nu fie compatibil cu dispozitivele mai vechi.", + "transcoding_threads": "Fire", + "transcoding_threads_description": "Valorile mai mari conduc la o codare mai rapidă, dar lasă mai puțin spațiu serverului pentru a procesa alte sarcini în timp ce este activ. Această valoare nu ar trebui să fie mai mare decât numărul de nuclee CPU. Maximizați utilizarea dacă este setat la 0.", + "transcoding_tone_mapping": "Mapare tonuri", + "transcoding_tone_mapping_description": "Încearcă să păstreze aspectul videoclipurilor HDR atunci când sunt convertite în SDR. Fiecare algoritm face compromisuri diferite pentru culoare, detalii și strălucire. Hable păstrează detaliile, Mobius păstrează culoarea, iar Reinhard păstrează strălucirea.", + "transcoding_tone_mapping_npl": "Mapare tonuri NPL", + "transcoding_tone_mapping_npl_description": "Culorile vor fi ajustate pentru a arăta normal pe un ecran cu această strălucire. În mod contraintuitiv, valorile mai mici cresc strălucirea videoclipului și invers, deoarece compensează pentru strălucirea ecranului. 0 setează această valoare automat.", + "transcoding_transcode_policy": "Politica de transcodare", + "transcoding_transcode_policy_description": "Politica pentru când un videoclip ar trebui să fie transcodificat. Videoclipurile HDR vor fi întotdeauna transcodificate (cu excepția cazului în care transcodarea este dezactivată).", + "transcoding_two_pass_encoding": "Codare în două treceri", + "transcoding_two_pass_encoding_setting_description": "Transcodificare în două treceri pentru a produce videoclipuri codificate mai bine. Când rata maximă de biți este activată (necesară pentru a funcționa cu H.264 și HEVC), acest mod utilizează un interval de rată de biți bazat pe rata maximă de biți și ignoră CRF. Pentru VP9, CRF poate fi utilizat dacă rata maximă de biți este dezactivată.", + "transcoding_video_codec": "Codec video", + "transcoding_video_codec_description": "VP9 are eficiențǎ mare și compatibilitate web, însǎ transcodarea este de duratǎ mai mare. HEVC se comportǎ asemǎnǎtor, însǎ are compatibilitate web mai micǎ. H.264 este foarte compatibil și rapid în transcodare, însǎ genereazǎ fișiere mult mai mari. AV1 este cel mai eficient codec dar nu este compatibil cu dispozitivele mai vechi.", + "trash_enabled_description": "Activează funcțiile Coș de gunoi", + "trash_number_of_days": "Numǎr de zile", + "trash_number_of_days_description": "Numǎr de zile pentru pǎstrarea fișierelor în coșul de gunoi pânǎ la ștergerea permanentǎ", + "trash_settings": "Setǎri coș de gunoi", + "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", + "untracked_files": "Fișiere neurmărite", + "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_cleanup_job": "Curățare utilizator", + "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", + "user_delete_delay_settings": "Întârziere la ștergere", + "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", + "user_delete_immediately": "Contul și resursele utilizatorului {user} vor fi puse în coadă pentru ștergere permanentă imediat.", + "user_delete_immediately_checkbox": "Pune utilizatorul și resursele în coadă pentru ștergere imediată", + "user_management": "Gestionarea Utilizatorilor", + "user_password_has_been_reset": "Parola utilizatorului a fost resetată:", + "user_password_reset_description": "Vă rugăm să furnizați utilizatorului parola temporară și să îi informați că va trebui să o schimbe la următoarea autentificare.", + "user_restore_description": "Contul utilizatorului {user} va fi restaurat.", + "user_restore_scheduled_removal": "Restaurare utilizator - ștergere programată pe {date, date, long}", + "user_settings": "Setǎri utilizator", + "user_settings_description": "Gestioneazǎ setǎrile utilizatorului", + "user_successfully_removed": "Utilizatorul {email} a fost eliminat cu succes.", + "version_check_enabled_description": "Activează verificarea versiunii", + "version_check_implications": "Funcția de verificare a versiunii se bazează pe comunicarea periodică cu github.com", + "version_check_settings": "Verificare versiune", + "version_check_settings_description": "Activeazǎ/dezactiveazǎ notificarea unei noi versiuni", + "video_conversion_job": "Transcodați videoclipuri", + "video_conversion_job_description": "Transcodați videoclipurile pentru o compatibilitate mai mare cu browserele și dispozitivele" + }, + "admin_email": "E-mailul administratorului", + "admin_password": "Parolă administrator", + "administration": "Administrare", + "advanced": "Avansat", + "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", + "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", + "age_years": "{years, plural, other {Vârstă #}}", + "album_added": "Album adăugat", + "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", + "album_cover_updated": "Coperta albumului a fost actualizată", + "album_delete_confirmation": "Ești sigur că vrei să ștergi albumul {album}?", + "album_delete_confirmation_description": "Dacă acest album este partajat, alți utilizatori nu vor mai putea accesa.", + "album_info_updated": "Informații album actualizate", + "album_leave": "Lăsați albumul?", + "album_leave_confirmation": "Ești sigur că dorești să părăsești {album}?", + "album_name": "Nume album", + "album_options": "Opțiuni album", + "album_remove_user": "Eliminare utilizator?", + "album_remove_user_confirmation": "Ești sigur că dorești eliminarea {user}?", + "album_share_no_users": "Se pare că ai partajat acest album cu toți utilizatorii sau nu ai niciun utilizator cu care să-l partajezi.", + "album_updated": "Album actualizat", + "album_updated_setting_description": "Primiți o notificare prin e-mail când un album partajat are elemente noi", + "album_user_left": "A părăsit {album}", + "album_user_removed": "{user} eliminat", + "album_with_link_access": "Permite oricui cu link-ul să vadă fotografiile și persoanele din acest album.", + "albums": "Albume", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}", + "all": "Toate", + "all_albums": "Toate albumele", + "all_people": "Toți oamenii", + "all_videos": "Toate videoclipurile", + "allow_dark_mode": "Permite mod întunecat", + "allow_edits": "Permite editări", + "allow_public_user_to_download": "Permite utilizatorului public să descarce", + "allow_public_user_to_upload": "Permite utilizatorului public să încarce", + "anti_clockwise": "În sens invers acelor de ceasornic", + "api_key": "Cheie API", + "api_key_description": "Această valoare va fi afișată o singură dată. Vă rugăm să vă asigurați că o copiați înainte de a închide fereastra.", + "api_key_empty": "Numele cheii API nu trebuie să fie gol", + "api_keys": "Chei API", + "app_settings": "Setări Aplicație", + "appears_in": "Apare în", + "archive": "Arhivă", + "archive_or_unarchive_photo": "Arhiveazǎ sau dezarhiveazǎ fotografia", + "archive_size": "Mărime arhivă", + "archive_size_description": "Configurează dimensiunea arhivei pentru descărcări (în GiB)", + "archived": "", + "archived_count": "{count, plural, other {Arhivat/e#}}", + "are_these_the_same_person": "Sunt aceștia aceeași persoană?", + "are_you_sure_to_do_this": "Sunteți sigur că doriți să faceți acest lucru?", + "asset_added_to_album": "Adăugat la album", + "asset_adding_to_album": "Se adaugă la album...", + "asset_description_updated": "Descrierea activelor a fost actualizată", + "asset_filename_is_offline": "Resursa {filename} este offline", + "asset_has_unassigned_faces": "Resursa are fețe neatribuite", + "asset_hashing": "Hașurare...", + "asset_offline": "Resursă Offline", + "asset_offline_description": "Această resursă externă nu mai este găsită pe disc. Contactează te rog administratorul tău Immich pentru ajutor.", + "asset_skipped": "Sărit", + "asset_skipped_in_trash": "În gunoi", + "asset_uploaded": "Încărcat", + "asset_uploading": "Se incarcă...", + "assets": "Resurse", + "assets_added_count": "Adăugat {count, plural, one {# resursă} other {# resurse}}", + "assets_added_to_album_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în album", + "assets_added_to_name_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în {hasName, select, true {{name}} other {albumul nou}}", + "assets_count": "{count, plural, one {# resursă} other {# resurse}}", + "assets_moved_to_trash_count": "Am mutat {count, plural, one {# resursă} other {# resurse}} în coșul de gunoi", + "assets_permanently_deleted_count": "Șters permanent {count, plural, one {# resursă} other {# resurse}}", + "assets_removed_count": "Eliminat {count, plural, one {# resursă} other {# resurse}}", + "assets_restore_confirmation": "Ești sigur că vrei să restaurezi toate resursele tale din coșul de gunoi? Nu poți anula această acțiune! Ține minte că resursele offline nu se restaurează astfel.", + "assets_restored_count": "Restaurat {count, plural, one {# resursă} other {# resurse}}", + "assets_trashed_count": "Mutat în coșul de gunoi {count, plural, one {# resursă} other {# resurse}}", + "assets_were_part_of_album_count": "{count, plural, one {Resursa era} other {Resursele erau}} deja parte din album", + "authorized_devices": "Dispozitive autorizate", + "back": "Înapoi", + "back_close_deselect": "Înapoi, închidere sau deselectare", + "backward": "În sens invers", + "birthdate_saved": "Data nașterii salvată cu succes", + "birthdate_set_description": "Data nașterii este utilizată pentru a calcula vârsta acestei persoane la momentul realizării fotografiei.", + "blurred_background": "Fundal neclar", + "build": "Construiți", + "build_image": "Construiți imagine", + "bulk_delete_duplicates_confirmation": "Ești sigur că vrei să ștergi în masă {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va șterge permanent toate celelalte duplicate. Nu poți anula această acțiune!", + "bulk_keep_duplicates_confirmation": "Ești sigur că vrei să păstrezi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va rezolva toate grupurile duplicate fără a șterge nimic.", + "bulk_trash_duplicates_confirmation": "Ești sigur că vrei să muți în coșul de gunoi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va muta în coșul de gunoi toate celelalte duplicate.", + "buy": "Achiziționează Immich", + "camera": "Camerǎ", + "camera_brand": "Marcǎ cameră", + "camera_model": "Model cameră", + "cancel": "Anulează", + "cancel_search": "Anulează căutarea", + "cannot_merge_people": "Nu se pot îmbina oamenii", + "cannot_undo_this_action": "Nu puteți anula această acțiune!", + "cannot_update_the_description": "Nu se poate actualiza descrierea", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "Schimbă dată", + "change_expiration_time": "Shimbă dată expirare", + "change_location": "Schimbă locația", + "change_name": "Schimbă nume", + "change_name_successfully": "Schimbare nume cu succes", + "change_password": "Schimbă Parolă", + "change_password_description": "Aceasta este fie prima dată când te conectezi în sistem, fie s-a făcut o solicitare pentru a schimba parola ta. Te rog să introduci noua parolă mai jos.", + "change_your_password": "Schimbă-ți parola", + "changed_visibility_successfully": "Schimbare vizibilitate cu succes", + "check_all": "Selectează Tot", + "check_logs": "Verifică Jurnale", + "choose_matching_people_to_merge": "Alegeți persoanele care se potrivesc pentru a le fuziona", + "city": "Oraș", + "clear": "Curăță", + "clear_all": "Curăță tot", + "clear_all_recent_searches": "Curăță toate căutările recente", + "clear_message": "Șterge mesajul", + "clear_value": "Șterge valoare", + "clockwise": "În sensul acelor de ceas", + "close": "Închide", + "collapse": "Restrânge", + "collapse_all": "Restrânge pe toate", + "color": "Culoare", + "color_theme": "Tema de culoare", + "comment_deleted": "Comentariu șters", + "comment_options": "Opțiuni comentariu", + "comments_and_likes": "Comentarii & aprecieri", + "comments_are_disabled": "Comentariile sunt dezactivate", + "confirm": "Confirmați", + "confirm_admin_password": "Confirmați parola de administrator", + "confirm_delete_shared_link": "Sunteți sigur că doriți să ștergeți acest link partajat?", + "confirm_password": "Confirmați parola", + "contain": "Încadrează", + "context": "Context", + "continue": "Continuați", + "copied_image_to_clipboard": "Imaginea copiată în clipboard.", + "copied_to_clipboard": "Copiat în clipboard!", + "copy_error": "Eroare de copiere", + "copy_file_path": "Copiați calea fișierului", + "copy_image": "Copiere imagine", + "copy_link": "Copiere link", + "copy_link_to_clipboard": "Copiați link-ul în clipboard", + "copy_password": "Copiați parola", + "copy_to_clipboard": "Copiere în Clipboard", + "country": "Țara", + "cover": "Umple fereastra", + "covers": "Acoperă", + "create": "Creează", + "create_album": "Creează album", + "create_library": "Creează bibliotecă", + "create_link": "Creează link", + "create_link_to_share": "Creează link pentru a distribui", + "create_link_to_share_description": "Permiteți oricui are link-ul să vadă fotografia (fotografiile) selectată(e)", + "create_new_person": "Creați o persoană nouă", + "create_new_person_hint": "Atribuiți resursele selectate unei persoane noi", + "create_new_user": "Creează utilizator nou", + "create_tag": "Creează etichetă", + "create_tag_description": "Creează o etichetă nouă. Pentru etichete imbricate, te rog să introduci calea completă a etichetei, inclusiv bare oblice (/).", + "create_user": "Creează utilizator", + "created": "Creat", + "current_device": "Dispozitiv curent", + "custom_locale": "Setare regională personalizată", + "custom_locale_description": "Formatați datele și numerele în funcție de limbă și regiune", + "dark": "Întunecat", + "date_after": "Dată după", + "date_and_time": "Dată și Oră", + "date_before": "Dată anterioară", + "date_of_birth_saved": "Data nașterii salvată cu succes", + "date_range": "Interval de date", + "day": "Zi", + "deduplicate_all": "Deduplicați toate", + "default_locale": "Setare regionlă implicită", + "default_locale_description": "Formatați datele și numerele în funcție de regiunea browserului dvs", + "delete": "Șterge", + "delete_album": "Șterge album", + "delete_api_key_prompt": "Sunteți sigur că doriți să ștergeți această cheie API?", + "delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți permanent aceste duplicate?", + "delete_key": "Șterge cheie", + "delete_library": "Șterge Biblioteca", + "delete_link": "Șterge linkul", + "delete_shared_link": "Șterge link-ul partajat", + "delete_tag": "Șterge etichetă", + "delete_tag_confirmation_prompt": "Ești sigur că vrei să ștergi eticheta {tagName} ?", + "delete_user": "Șterge utilizator", + "deleted_shared_link": "Link partajat șters", + "deletes_missing_assets": "Șterge resursele lipsă de pe disc", + "description": "Descriere", + "details": "Detalii", + "direction": "Direcție", + "disabled": "Dezactivat", + "disallow_edits": "Interzice modificările", + "discord": "Discord", + "discover": "Descoperiți", + "dismiss_all_errors": "Ignoră toate erorile", + "dismiss_error": "Ignorați eroarea", + "display_options": "Opțiuni de afișare", + "display_order": "Ordine de afișare", + "display_original_photos": "Afișați fotografiile originale", + "display_original_photos_setting_description": "Preferă să afișezi fotografia originală atunci când vizualizezi o resursă, în loc de miniaturi, atunci când resursa originală este compatibilă cu web-ul. Aceasta poate duce la viteze mai lente de afișare a fotografiilor.", + "do_not_show_again": "Nu mai afișa acest mesaj", + "documentation": "Documentație", + "done": "Gata", + "download": "Descarcă", + "download_include_embedded_motion_videos": "Videoclipuri încorporate", + "download_include_embedded_motion_videos_description": "Include videoclipurile încorporate în fotografiile în mișcare ca fișier separat", + "download_settings": "Descarcă", + "download_settings_description": "Gestionați setările legate de descărcarea resurselor", + "downloading": "Se descarcă", + "downloading_asset_filename": "Se descarcă resursa {filename}", + "drop_files_to_upload": "Trage fișierele aici pentru a le încărca", + "duplicates": "Duplicate", + "duplicates_description": "Rezolvați fiecare grup indicând care sunt duplicate, dacă există", + "duration": "Durată", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "Modifică", + "edit_album": "Modificare album", + "edit_avatar": "Modificare avatar", + "edit_date": "Modifică data", + "edit_date_and_time": "Modifică data și ora", + "edit_exclusion_pattern": "Editarea modelului de excludere", + "edit_faces": "Modifică fețele", + "edit_import_path": "Editarea căii de import", + "edit_import_paths": "Editarea căilor de import", + "edit_key": "Tastă de editare", + "edit_link": "Modifică link", + "edit_location": "Editează locație", + "edit_name": "Editează nume", + "edit_people": "Editează persoane", + "edit_tag": "Modifică etichetă", + "edit_title": "Editează Titlul", + "edit_user": "Modifică utilizator", + "edited": "Editat", + "editor": "Editor", + "editor_close_without_save_prompt": "Schimbările nu vor fi salvate", + "editor_close_without_save_title": "Închizi editorul?", + "editor_crop_tool_h2_aspect_ratios": "Raporturi de aspect", + "editor_crop_tool_h2_rotation": "Rotire", + "email": "Email", + "empty": "", + "empty_album": "", + "empty_trash": "Golește coșul", + "empty_trash_confirmation": "Sunteți sigur că doriți să goliți coșul de gunoi? Acest lucru va elimina definitiv din Immich toate bunurile din coșul de gunoi.\nNu puteți anula această acțiune!", + "enable": "Activează", + "enabled": "Activat", + "end_date": "Data de încheiere", + "error": "Eroare", + "error_loading_image": "Eroare la incarcarea fotografiei", + "error_title": "Eroare - Ceva nu a mers", + "errors": { + "cannot_navigate_next_asset": "Nu se poate naviga către următorul activ", + "cannot_navigate_previous_asset": "Nu se poate naviga la activul anterior", + "cant_apply_changes": "Nu se pot aplica schimbări", + "cant_change_activity": "Nu se poate {enabled, select, true {dezactiva} other {activa}} activitatea", + "cant_change_asset_favorite": "Nu pot schimba favoritul pentru activ", + "cant_change_metadata_assets_count": "Nu se pot modifica metadatele pentru {count, plural, one {# element} other {# elemente}}", + "cant_get_faces": "Nu pot obține fețe", + "cant_get_number_of_comments": "Nu pot obține numărul de comentarii", + "cant_search_people": "Nu pot căuta oameni", + "cant_search_places": "Nu se pot căuta locații", + "cleared_jobs": "Joburi terminate pentru: {job}", + "error_adding_assets_to_album": "Eroare la adăugarea activelor la album", + "error_adding_users_to_album": "Eroare la adăugarea utilizatorilor la album", + "error_deleting_shared_user": "Eroare la ștergerea utilizatorului partajat", + "error_downloading": "Eroare la descărcarea {filename}", + "error_hiding_buy_button": "Eroare la ascunderea butonului de cumpărare", + "error_removing_assets_from_album": "Eroare la eliminarea activelor din album, verificați consola pentru mai multe detalii", + "error_selecting_all_assets": "Eroare la selectarea tuturor activelor", + "exclusion_pattern_already_exists": "Acest model de excludere există deja.", + "failed_job_command": "Comanda {command} a eșuat pentru job: {job}", + "failed_to_create_album": "A eșuat crearea albumului", + "failed_to_create_shared_link": "A eșuat crearea legăturii partajate", + "failed_to_edit_shared_link": "A eșuat editarea legăturii partajate", + "failed_to_get_people": "Eșec la obținerea persoanelor", + "failed_to_load_asset": "Eșec la încărcarea resursei", + "failed_to_load_assets": "Eșec la încărcarea resurselor", + "failed_to_load_people": "Eșec la încărcarea oamenilor", + "failed_to_remove_product_key": "Eșec la eliminarea cheii de produs", + "failed_to_stack_assets": "Eșec la combinarea resurselor", + "failed_to_unstack_assets": "Eșec la desfășurarea resurselor", + "import_path_already_exists": "Această cale de import există deja.", + "incorrect_email_or_password": "E-mail sau parolă incorect/ă", + "paths_validation_failed": "{paths, plural, one {# cale} other {# căi}} nu a trecut validarea", + "profile_picture_transparent_pixels": "Pozele de profil nu pot avea pixeli transparenți. Te rugăm să mărești imaginea și/sau să o muți.", + "quota_higher_than_disk_size": "Ai stabilit o cotă mai mare decât dimensiunea discului", + "repair_unable_to_check_items": "Imposibil de verificat {count, select, one {element} other {elemente}}", + "unable_to_add_album_users": "Imposibil de adăugat utilizatori în album", + "unable_to_add_assets_to_shared_link": "Imposibil de adăugat resurse la link-ul partajat", + "unable_to_add_comment": "Imposibil de adăugat comentariu", + "unable_to_add_exclusion_pattern": "Nu se poate adăuga modelul de excluziune", + "unable_to_add_import_path": "Imposibil de adăugat calea de import", + "unable_to_add_partners": "Nu se poate de adăuga parteneri", + "unable_to_add_remove_archive": "Nu se poate {archived, select, true {îndepărta resursa din} other {adăuga resursa în}} arhivă", + "unable_to_add_remove_favorites": "Nu se poate {favorite, select, true {adăuga resursa în} other {îndepărta resursa din}} favorite", + "unable_to_archive_unarchive": "Nu se poate {archived, select, true {arhiva} other {dezarhiva}}", + "unable_to_change_album_user_role": "Nu se poate schimba rolul utilizatorului de album", + "unable_to_change_date": "Imposibil de schimbat data", + "unable_to_change_favorite": "Nu se poate modifica favoritele pentru resursă", + "unable_to_change_location": "Imposibil de schimbat locația", + "unable_to_change_password": "Imposibil de schimbat parola", + "unable_to_change_visibility": "Nu se poate schimba vizibilitatea pentru {count, plural, one {# persoană} other {# persoane}}", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "Nu putut fi realizată logarea prin OAuth", + "unable_to_connect": "Nu se poate conecta", + "unable_to_connect_to_server": "Nu se poate conecta la server", + "unable_to_copy_to_clipboard": "Nu poate fi copiat, asigură-te că accesezi pagina prin https", + "unable_to_create_admin_account": "Nu se poate crea contul de administrator", + "unable_to_create_api_key": "Nu se poate crea o nouă cheie API", + "unable_to_create_library": "Nu se poate crea biblioteca", + "unable_to_create_user": "Nu se poate crea userul", + "unable_to_delete_album": "Nu se poate șterge albumul", + "unable_to_delete_asset": "Nu poate fi ștearsă resursa", + "unable_to_delete_assets": "Eroare la ștergerea resurselor", + "unable_to_delete_user": "Nu se poate șterge userul", + "unable_to_download_files": "Nu se pot descărca fișierele", + "unable_to_empty_trash": "", + "unable_to_enter_fullscreen": "", + "unable_to_exit_fullscreen": "", + "unable_to_hide_person": "Nu se poate ascunde persoana", + "unable_to_load_album": "Nu se poate încărca albumul", + "unable_to_load_asset_activity": "", + "unable_to_load_items": "", + "unable_to_load_liked_status": "", + "unable_to_play_video": "Nu se poate reda videoul", + "unable_to_refresh_user": "", + "unable_to_remove_album_users": "Nu se pot șterge userii din album", + "unable_to_remove_api_key": "Nu se poate șterge cheia API", + "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Nu se pot șterge fișierele offline", + "unable_to_remove_library": "Nu se poate șterge biblioteca", + "unable_to_remove_offline_files": "Nu se pot șterge fișierele offline", + "unable_to_remove_partner": "Imposibil de eliminat partenerul", + "unable_to_remove_reaction": "Nu se poate elimina reația", + "unable_to_remove_user": "", + "unable_to_repair_items": "Imposibil de a repara elementele", + "unable_to_reset_password": "Imposibil de a reseta parola", + "unable_to_resolve_duplicate": "Nu se poate de rezolvat duplicatul", + "unable_to_restore_assets": "Nu se pot restaura resursele", + "unable_to_restore_trash": "Nu se poate restaura coșul de gunoi", + "unable_to_restore_user": "Nu se poate restaura utilizatorul", + "unable_to_save_album": "Imposibil de salvat albumul", + "unable_to_save_api_key": "Imposibil de salvat cheia API", + "unable_to_save_date_of_birth": "Imposibil de a salva data de naștere", + "unable_to_save_name": "Imposibil de a salva numele", + "unable_to_save_profile": "Imposibil de a salva profilul", + "unable_to_save_settings": "Nu se pot salva setările", + "unable_to_scan_libraries": "Nu se pot scana librăriile", + "unable_to_scan_library": "Nu se poate de scanat librăria", + "unable_to_set_feature_photo": "Nu se poate seta fotografia principală", + "unable_to_set_profile_picture": "Nu se poate seta fotografia de profil", + "unable_to_submit_job": "", + "unable_to_trash_asset": "", + "unable_to_unlink_account": "", + "unable_to_update_album_cover": "Nu se poate actualiza coperta de album", + "unable_to_update_library": "Nu se poate actualiza biblioteca", + "unable_to_update_location": "Nu se poate actualiza locația", + "unable_to_update_settings": "Nu se pot actualiza setările", + "unable_to_update_user": "Nu se poate actualiza utilizatorul" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exit_slideshow": "", + "expand_all": "", + "expire_after": "Expiră după", + "expired": "Expirat", + "explore": "Exploreazǎ", + "extension": "Extensie", + "external": "Extern", + "external_libraries": "", + "failed_to_get_people": "", + "favorite": "", + "favorite_or_unfavorite_photo": "", + "favorites": "Favorite", + "feature": "", + "feature_photo_updated": "", + "featurecollection": "", + "file_name": "", + "file_name_or_extension": "", + "filename": "", + "files": "", + "filetype": "", + "filter_people": "", + "fix_incorrect_match": "", + "force_re-scan_library_files": "", + "forward": "", + "general": "", + "get_help": "", + "getting_started": "", + "go_back": "", + "go_to_search": "", + "go_to_share_page": "", + "group_albums_by": "", + "has_quota": "", + "hide_gallery": "", + "hide_password": "", + "hide_person": "", + "host": "", + "hour": "", + "image": "", + "img": "", + "immich_logo": "", + "import_path": "", + "in_archive": "", + "include_archived": "Include resursele arhivate", + "include_shared_albums": "", + "include_shared_partner_assets": "", + "individual_share": "", + "info": "", + "interval": { + "day_at_onepm": "", + "hours": "", + "night_at_midnight": "", + "night_at_twoam": "" + }, + "invite_people": "", + "invite_to_album": "Invită în album", + "job_settings_description": "", + "jobs": "", + "keep": "", + "keyboard_shortcuts": "", + "language": "", + "language_setting_description": "", + "last_seen": "", + "leave": "", + "let_others_respond": "Permite altora să răspundă", + "level": "", + "library": "Librărie", + "library_options": "", + "light": "", + "link_options": "", + "link_to_oauth": "", + "linked_oauth_account": "", + "list": "", + "loading": "", + "loading_search_results_failed": "", + "log_out": "Deconectare", + "log_out_all_devices": "", + "login_has_been_disabled": "", + "look": "", + "loop_videos": "", + "loop_videos_description": "", + "make": "", + "manage_shared_links": "Administrează link-urile distribuite", + "manage_sharing_with_partners": "", + "manage_the_app_settings": "", + "manage_your_account": "", + "manage_your_api_keys": "", + "manage_your_devices": "", + "manage_your_oauth_connection": "", + "map": "", + "map_marker_with_image": "", + "map_settings": "Setările hărții", + "media_type": "Tip fișier", + "memories": "Amintiri", + "memories_setting_description": "Administreazǎ ce vezi în amintiri", + "memory": "Amintire", + "menu": "Meniu", + "merge": "", + "merge_people": "", + "merge_people_successfully": "", + "minimize": "", + "minute": "", + "missing": "Absente", + "model": "Model", + "month": "Lună", + "more": "Mai multe", + "moved_to_trash": "", + "my_albums": "Albumele mele", + "name": "Nume", + "name_or_nickname": "Nume sau poreclǎ", + "never": "Niciodată", + "new_api_key": "Cheie API nouǎ", + "new_password": "Parolă nouă", + "new_person": "Persoanǎ nouǎ", + "new_user_created": "Utilizator nou creat", + "newest_first": "", + "next": "Următorul", + "next_memory": "", + "no": "", + "no_albums_message": "", + "no_archived_assets_message": "", + "no_assets_message": "", + "no_exif_info_available": "", + "no_explore_results_message": "", + "no_favorites_message": "", + "no_libraries_message": "", + "no_name": "", + "no_places": "", + "no_results": "", + "no_shared_albums_message": "", + "not_in_any_album": "", + "notes": "", + "notification_toggle_setting_description": "", + "notifications": "Notificări", + "notifications_setting_description": "", + "oauth": "", + "offline": "", + "ok": "", + "oldest_first": "", + "online": "", + "only_favorites": "", + "only_refreshes_modified_files": "", + "open_the_search_filters": "", + "options": "Opțiuni", + "organize_your_library": "", + "other": "", + "other_devices": "", + "other_variables": "", + "owned": "Deținut", + "owner": "Admin", + "partner": "Partener", + "partner_sharing": "", + "partners": "", + "password": "Parolă", + "password_does_not_match": "", + "password_required": "", + "password_reset_success": "", + "past_durations": { + "days": "", + "hours": "", + "years": "" + }, + "path": "", + "pattern": "", + "pause": "", + "pause_memories": "", + "paused": "", + "pending": "", + "people": "Persoane", + "people_sidebar_description": "", + "perform_library_tasks": "", + "permanent_deletion_warning": "", + "permanent_deletion_warning_setting_description": "", + "permanently_delete": "", + "permanently_deleted_asset": "", + "person": "Persoanǎ", + "photos": "Fotografii", + "photos_from_previous_years": "", + "pick_a_location": "", + "place": "", + "places": "Locații", + "play": "", + "play_memories": "", + "play_motion_photo": "", + "play_or_pause_video": "", + "point": "", + "port": "", + "preset": "", + "preview": "", + "previous": "", + "previous_memory": "", + "previous_or_next_photo": "", + "primary": "", + "profile_picture_set": "", + "public_share": "", + "range": "", + "raw": "", + "reaction_options": "", + "read_changelog": "", + "recent": "", + "recent_searches": "", + "refresh": "", + "refreshed": "", + "refreshes_every_file": "", + "remove": "", + "remove_deleted_assets": "", + "remove_from_album": "Șterge din album", + "remove_from_favorites": "", + "remove_from_shared_link": "", + "repair": "", + "repair_no_results_message": "", + "replace_with_upload": "", + "require_password": "", + "reset": "", + "reset_password": "", + "reset_people_visibility": "", + "reset_settings_to_default": "", + "restore": "Restaurează", + "restore_user": "", + "retry_upload": "", + "review_duplicates": "", + "role": "", + "save": "Salvează", + "saved_profile": "", + "saved_settings": "", + "say_something": "Spune ceva", + "scan_all_libraries": "", + "scan_all_library_files": "", + "scan_new_library_files": "", + "scan_settings": "", + "search": "Caută", + "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": "Tip cǎutare", + "search_your_photos": "Căutare fotografii", + "searching_locales": "", + "second": "Secundǎ", + "select_album_cover": "", + "select_all": "", + "select_all_duplicates": "Selecteazǎ toate duplicatele", + "select_avatar_color": "", + "select_face": "Selecteazǎ fațǎ", + "select_featured_photo": "", + "select_from_computer": "Selecteazǎ din calculator", + "select_keep_all": "Selecteazǎ tot pentru salvare", + "select_library_owner": "Selecteazǎ proprietarul bibliotecii", + "select_new_face": "Selecteazǎ o nouǎ fațǎ", + "select_photos": "Selectează fotografii", + "select_trash_all": "Selecteazǎ tot pentru ștergere", + "selected": "Selectați", + "send_message": "", + "server": "", + "server_stats": "", + "set": "", + "set_as_album_cover": "", + "set_as_profile_picture": "", + "set_date_of_birth": "", + "set_profile_picture": "", + "set_slideshow_to_fullscreen": "", + "settings": "Setări", + "settings_saved": "", + "share": "Distribuie", + "shared": "Distribuit", + "shared_by": "", + "shared_by_you": "", + "shared_links": "Link-uri distribuite", + "sharing": "Distribuire", + "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": "", + "show_metadata": "Arată metadata", + "show_or_hide_info": "", + "show_password": "", + "show_person_options": "", + "show_progress_bar": "", + "show_search_options": "", + "shuffle": "", + "sign_up": "", + "size": "", + "skip_to_content": "", + "slideshow": "", + "slideshow_settings": "", + "sort_albums_by": "", + "stack": "Grup", + "stack_selected_photos": "", + "stacktrace": "", + "start_date": "", + "state": "", + "status": "", + "stop_motion_photo": "", + "stop_photo_sharing": "Încetezi distribuirea fotografiilor?", + "storage": "Spațiu de stocare", + "storage_label": "", + "storage_usage": "{used} din {available} utilizați", + "submit": "", + "suggestions": "Sugestii", + "sunrise_on_the_beach": "Rǎsǎrit pe plajǎ", + "swap_merge_direction": "", + "sync": "Sincronizare", + "template": "", + "theme": "Temă", + "theme_selection": "", + "theme_selection_description": "", + "time_based_memories": "", + "timezone": "Fus orar", + "to_favorite": "Favorit", + "toggle_settings": "", + "toggle_theme": "", + "toggle_visibility": "", + "total_usage": "", + "trash": "Coș", + "trash_all": "Șterge tot", + "trash_count": "Șterge {count, number}", + "trash_no_results_message": "", + "type": "", + "unarchive": "Șterge din arhivă", + "unarchived": "", + "unfavorite": "Șterge din favorite", + "unhide_person": "", + "unknown": "", + "unknown_album": "", + "unknown_year": "", + "unlink_oauth": "", + "unlinked_oauth_account": "", + "unselect_all": "", + "unstack": "Anulează grup", + "up_next": "", + "updated_password": "", + "upload": "Încarcă", + "upload_concurrency": "", + "url": "", + "usage": "", + "user": "", + "user_id": "", + "user_usage_detail": "", + "username": "", + "users": "Utilizatori", + "utilities": "Utilitǎți", + "validate": "Valideazǎ", + "variables": "Variabile", + "version": "Versiune", + "version_announcement_closing": "Prietenul tǎu, Alex", + "video": "Videoclip", + "video_hover_setting_description": "", + "videos": "Videoclipuri", + "view_album": "Vezi album", + "view_all": "Vezi toate", + "view_all_users": "Vezi toți utilizatorii", + "view_links": "Vezi scurtǎturi", + "view_next_asset": "", + "view_previous_asset": "", + "viewer": "", + "waiting": "În așteptare", + "warning": "Avertisment", + "week": "Sǎptǎmânǎ", + "welcome": "Salutare", + "welcome_to_immich": "Bun venit în Immich", + "year": "An", + "years_ago": "acum {years, plural, one {# an} other {# ani}}", + "yes": "Da", + "you_dont_have_any_shared_links": "Nu aveți niciun link partajat", + "zoom_image": "Mărește imaginea" +} diff --git a/web/src/lib/i18n/ru.json b/i18n/ru.json similarity index 82% rename from web/src/lib/i18n/ru.json rename to i18n/ru.json index 09043808a3..fd4be156eb 100644 --- a/web/src/lib/i18n/ru.json +++ b/i18n/ru.json @@ -1,7 +1,7 @@ { "about": "О продукте", "account": "Учётная запись", - "account_settings": "Настройки учётной записи", + "account_settings": "Настройки аккаунта", "acknowledge": "Подтвердить", "action": "Действие", "actions": "Действия", @@ -28,6 +28,7 @@ "added_to_favorites_count": "Добавлено{count, number} в избранное", "admin": { "add_exclusion_pattern_description": "Добавьте шаблоны исключений. Подстановка с использованием *, ** и ? поддерживается. Чтобы игнорировать все файлы в любом каталоге с именем «Raw», используйте «**/Raw/**». Чтобы игнорировать все файлы, заканчивающиеся на «.tif», используйте «**/*.tif». Чтобы игнорировать абсолютный путь, используйте «/path/to/ignore/**».", + "asset_offline_description": "Этот файл внешней библиотеки не был найден на диске и был перемещён в корзину. Если файл был перемещён внутри библиотеки, проверьте временную шкалу, чтобы найти новый соответствующий ресурс. Чтобы восстановить файл, убедитесь, что путь ниже доступен для Immich и выполните сканирование библиотеки.", "authentication_settings": "Настройки аутентификации", "authentication_settings_description": "Управление паролями, OAuth и другими настройками аутентификации", "authentication_settings_disable_all": "Вы уверены, что хотите отключить все методы входа? Вход будет полностью отключен.", @@ -37,10 +38,11 @@ "cleared_jobs": "Очищены задачи для: {job}", "config_set_by_file": "Настроено с помощью файла конфигурации", "confirm_delete_library": "Вы действительно хотите удалить библиотеку \"{library}\"?", - "confirm_delete_library_assets": "Вы уверены, что хотите удалить эту библиотеку? Это безвозвратно удалит {count, plural, one {# содержимый объект} few {# содержимых объекта} other {all # содержимых объектов}} с Immich. Файлы останутся на диске.", + "confirm_delete_library_assets": "Вы уверены, что хотите удалить эту библиотеку? Это безвозвратно удалит {count, plural, one {# содержимый объект} few {# содержимых объекта} other {all # содержимых объектов}} из Immich. Файлы останутся на диске.", "confirm_email_below": "Чтобы подтвердить, введите \"{email}\" ниже", "confirm_reprocess_all_faces": "Вы уверены, что хотите повторно определить все лица? Будут также удалены имена со всех лиц.", "confirm_user_password_reset": "Вы уверены, что хотите сбросить пароль пользователя {user}?", + "create_job": "Создать задание", "crontab_guru": "Crontab Guru", "disable_login": "Отключить вход", "disabled": "Выключено", @@ -49,27 +51,37 @@ "external_library_created_at": "Внешняя библиотека (создана {date})", "external_library_management": "Управление внешними библиотеками", "face_detection": "Обнаружение лиц", - "face_detection_description": "Обнаруживает лица на ресурсах с помощью машинного обучения. Для видео учитывается только миниатюра. “Все” - обрабатывает все ресурсы. “Отсутствующие” - в очередь помещаются только не обработанные ресурсы. Обнаруженные лица будут помещены в очередь для распознавания лиц после завершения обнаружения лиц, объединяя их в существующие или новые группы людей.", - "facial_recognition_job_description": "Группирует распознанные лица по людям. Этот шаг выполняется после завершения обнаружения лиц. “Все” - группирует все лица. “Отсутствующие” - помещает в очередь лица, не привязанные к человеку.", + "face_detection_description": "Обнаруживает лица на медиа с помощью машинного обучения. Для видео учитывается только миниатюра. “Обновить” — обработать все медиа. “Сброс” — удалить все имеющиеся данные лиц и обработать заново. “Пропущенные” — добавить в очередь необработанные медиа. Обнаруженные лица будут помещены в очередь распознавания для привязки к существующим или новым людям.", + "facial_recognition_job_description": "Группирует распознанные лица по людям. Этот шаг выполняется после завершения обнаружения лиц. “Сброс” - группирует все лица. “Пропущенные” - помещает в очередь лица, не привязанные к человеку.", "failed_job_command": "Команда {command} не выполнена для задачи: {job}", - "force_delete_user_warning": "ПРЕДУПРЕЖДЕНИЕ: Это приведет к немедленному удалению пользователя и всех ресурсов. Это невозможно отменить, и файлы не могут быть восстановлены.", + "force_delete_user_warning": "ПРЕДУПРЕЖДЕНИЕ: Это приведет к немедленному удалению пользователя и его ресурсов. Это действие невозможно отменить, и файлы не могут быть восстановлены.", "forcing_refresh_library_files": "Принудительное обновление всех файлов библиотеки", + "image_format": "Формат", "image_format_description": "WebP создает файлы меньшего размера, чем JPEG, но кодирует медленнее.", "image_prefer_embedded_preview": "Предпочитать встроенное превью", "image_prefer_embedded_preview_setting_description": "Используйте встроенные превью в фотографиях RAW в качестве входных данных для обработки изображений, если они доступны. Это может обеспечить более точную цветопередачу для некоторых изображений, но качество предварительного просмотра зависит от камеры, и изображение может иметь больше артефактов сжатия.", "image_prefer_wide_gamut": "Предпочитаю широкую гамму", "image_prefer_wide_gamut_setting_description": "Используйте Display P3 для миниатюр. Это лучше сохраняет яркость изображений с широким цветовым пространством, но изображения могут выглядеть по-другому на старых устройствах со старой версией браузера. Изображения sRGB сохраняются в формате sRGB, что позволяет избежать цветовых сдвигов.", + "image_preview_description": "Изображение среднего размера без метаданных, используемое при отдельном просмотре и для машинного обучения", "image_preview_format": "Формат превью", + "image_preview_quality_description": "Качество предварительного просмотра от 1 до 100. Чем выше, тем лучше, но при этом создаются файлы большего размера и может снизиться скорость отклика приложения. Установка низкого значения может повлиять на качество машинного обучения.", "image_preview_resolution": "Разрешение превью", "image_preview_resolution_description": "Используется при просмотре одной фотографии и для машинного обучения. Более высокие разрешения позволяют сохранить больше деталей, но требуют больше времени для кодирования, имеют больший размер файлов и могут снизить скорость отклика приложения.", + "image_preview_title": "Настройки предварительного просмотра", "image_quality": "Качество", "image_quality_description": "Качество изображения от 1 до 100. Чем выше число, тем лучше качество и больше вес изображения.", + "image_resolution": "Разрешение", + "image_resolution_description": "Более высокое разрешение позволяет сохранить больше деталей, но требует больше времени для кодирования, приводит к увеличению размера файлов и может снизить скорость отклика приложения.", "image_settings": "Настройки изображений", "image_settings_description": "Управление качеством и разрешением создаваемых изображений", + "image_thumbnail_description": "Маленькая миниатюра с удаленными метаданными, используемая при просмотре групп фотографий, таких как основная временная шкала", "image_thumbnail_format": "Формат миниатюр", + "image_thumbnail_quality_description": "Качество миниатюр от 1 до 100. Чем выше качество, тем лучше, но при этом создаются файлы большего размера и может снизиться скорость отклика приложения.", "image_thumbnail_resolution": "Разрешение миниатюр", "image_thumbnail_resolution_description": "Используется при просмотре групп фотографий (на временной шкале, при просмотре альбомов и т.д.). Миниатюры с более высоким разрешением сохраняют больше деталей, но требуют больше времени для кодирования, имеют больший вес и могут снизить скорость отклика приложения.", + "image_thumbnail_title": "Настройки миниатюр", "job_concurrency": "Параллельная обработка задания - {job}", + "job_created": "Задание создано", "job_not_concurrency_safe": "Эта задача не обеспечивает безопасность параллельности выполнения.", "job_settings": "Настройки заданий", "job_settings_description": "Управление параллельной обработкой заданий", @@ -92,13 +104,13 @@ "library_watching_settings": "Слежение за библиотекой (ЭКСПЕРИМЕНТАЛЬНОЕ)", "library_watching_settings_description": "Автоматически следить за изменениями файлов", "logging_enable_description": "Включить ведение журнала", - "logging_level_description": "Если включено, какой уровень логирования использовать.", + "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": "Если этот параметр отключен, абсолютно идентичные ресурсы всё равно будут удалены из дубликатов.", + "machine_learning_duplicate_detection_enabled_description": "Если этот параметр отключен, абсолютно идентичные файлы всё равно будут удалены из дубликатов.", "machine_learning_duplicate_detection_setting_description": "Используйте встраивания CLIP для поиска вероятных дубликатов", "machine_learning_enabled": "Включите машинное обучение", "machine_learning_enabled_description": "При отключении, все функции ML будут отключены независимо от следующих параметров.", @@ -110,7 +122,7 @@ "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": "Порог распознавания", "machine_learning_max_recognition_distance_description": "Максимальное различие между двумя лицами, которые можно считать одним и тем же человеком, в диапазоне 0-2. Понижение этого параметра может предотвратить распознавание двух людей как одного и того же человека, а повышение - как двух разных людей. Обратите внимание, что проще объединить двух людей, чем разделить одного человека на два, поэтому по возможности выбирайте меньший порог.", "machine_learning_min_detection_score": "Минимальный порог распознавания", "machine_learning_min_detection_score_description": "Минимальный порог для обнаружения лица от 0 до 1. Более низкие значения позволяют обнаружить больше лиц, но могут привести к ложным срабатываниям.", @@ -118,7 +130,7 @@ "machine_learning_min_recognized_faces_description": "Минимальное количество распознанных лиц для создания человека. Увеличение этого параметра делает распознавание лиц более точным, но при этом увеличивается вероятность того, что лицо не будет присвоено человеку.", "machine_learning_settings": "Настройки машинного обучения", "machine_learning_settings_description": "Управление функциями и настройками машинного обучения", - "machine_learning_smart_search": "Умный Поиск", + "machine_learning_smart_search": "Интеллектуальный поиск", "machine_learning_smart_search_description": "Семантический поиск изображений с использованием вложений CLIP", "machine_learning_smart_search_enabled": "Включить интеллектуальный поиск", "machine_learning_smart_search_enabled_description": "Если этот параметр отключен, изображения не будут кодироваться для интеллектуального поиска.", @@ -129,25 +141,30 @@ "map_enable_description": "Включить функции карты", "map_gps_settings": "Настройки карты и 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_reverse_geocoding_settings": "Настройки обратного геокодирования", "map_settings": "Настройки карты", "map_settings_description": "Управление настройками карты", "map_style_description": "URL-адрес темы карты style.json", "metadata_extraction_job": "Извлечение метаданных", - "metadata_extraction_job_description": "Извлекает метаданные из каждого ресурса, такие как координаты GPS и разрешение", + "metadata_extraction_job_description": "Извлекает метаданные из каждого файла, такие как местоположение, лица и разрешение", + "metadata_faces_import_setting": "Включить импорт лиц", + "metadata_faces_import_setting_description": "Импорт лиц из изображений EXIF-данных и файлов sidecar", + "metadata_settings": "Настройки метаданных", + "metadata_settings_description": "Управление настройками метаданных", "migration_job": "Миграция", - "migration_job_description": "Выполняет перенос миниатюр для ресурсов и лиц в последнюю структуру папок", + "migration_job_description": "Выполняет перенос миниатюр ресурсов и лиц в последнюю структуру папок", "no_paths_added": "Пути не добавлены", "no_pattern_added": "Шаблон не добавлен", - "note_apply_storage_label_previous_assets": "Примечание: Чтобы применить тег хранилища к ранее загруженным ресурсам запустите", + "note_apply_storage_label_previous_assets": "Примечание: Чтобы применить тег хранилища к ранее загруженным ресурсам, запустите", "note_cannot_be_changed_later": "ПРИМЕЧАНИЕ: Это невозможно изменить позже!", "note_unlimited_quota": "Примечание: Введите 0 для неограниченной квоты или оставьте пустым", "notification_email_from_address": "Адрес отправителя", - "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", "notification_email_host_description": "Доменное имя почтового сервера (например, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игнорировать ошибки сертификата", "notification_email_ignore_certificate_errors_description": "Игнорировать ошибки проверки сертификата TLS (не рекомендуется)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL-адрес эмитента", "oauth_mobile_redirect_uri": "URI редиректа для мобильных", "oauth_mobile_redirect_uri_override": "Перенаправление URI для мобильных устройств", - "oauth_mobile_redirect_uri_override_description": "Включите, если «app.immich:/» не подходит в качестве URI редиректа.", + "oauth_mobile_redirect_uri_override_description": "Включите, если поставщик OAuth не разрешает использование мобильного URI, например, '{callback}'", "oauth_profile_signing_algorithm": "Алгоритм подписи профиля", "oauth_profile_signing_algorithm_description": "Алгоритм, используемый для входа в профиль пользователя.", "oauth_scope": "Разрешения", @@ -193,22 +210,25 @@ "password_settings": "Настройки входа с паролем", "password_settings_description": "Управление настройками входа по паролю", "paths_validated_successfully": "Все пути успешно прошли проверку", + "person_cleanup_job": "Очистка персоны", "quota_size_gib": "Размер квоты (ГБ)", "refreshing_all_libraries": "Обновление всех библиотек", "registration": "Регистрация Администратора", "registration_description": "Поскольку вы являетесь первым пользователем в системе, вам будет присвоена роль администратора, и вы будете отвечать за административные задачи. Дополнительных пользователей будете создавать вы.", - "removing_offline_files": "Удаление недоступных файлов", + "removing_deleted_files": "Удаление недоступных файлов", "repair_all": "Починить всё", "repair_matched_items": "Соответствует {count, plural, one {# элементу} few {# элементам} many {# элементам} other {# элементам}}", "repaired_items": "Восстановлено {count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элемента}}", "require_password_change_on_login": "Требовать смену пароля при первом входе", "reset_settings_to_default": "Сброс настроек до значений по умолчанию", "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", + "scanning_library": "Сканирование библиотеки", "scanning_library_for_changed_files": "Поиск измененных файлов", "scanning_library_for_new_files": "Поиск новых файлов", + "search_jobs": "Поиск заданий...", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", - "server_external_domain_settings_description": "Домен для общедоступных ссылок, включая http(s)://", + "server_external_domain_settings_description": "Домен для публичных ссылок, включая http(s)://", "server_settings": "Настройки сервера", "server_settings_description": "Управление настройками сервера", "server_welcome_message": "Приветственное сообщение", @@ -216,23 +236,24 @@ "sidecar_job": "Метаданные из sidecar-файлов", "sidecar_job_description": "Обнаруживает и синхронизирует метаданные из sidecar-файлов", "slideshow_duration_description": "Количество секунд для отображения каждого изображения", - "smart_search_job_description": "Запускает машинное обучение на объектах для поддержки умного поиска", - "storage_template_date_time_description": "Время создание объекта использовано как информация о времени съемки", + "smart_search_job_description": "Распознает содержимое медиафайлов для умного поиска", + "storage_template_date_time_description": "Время создания файла использовано как информация о времени съемки", "storage_template_date_time_sample": "Время выборки {date}", "storage_template_enable_description": "Включить механизм шаблонов хранилища", - "storage_template_hash_verification_enabled": "Включено проверку хеша", + "storage_template_hash_verification_enabled": "Включить проверку хеша", "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_more_details": "Для получения дополнительной информации об этой функции обратитесь к Шаблону хранилища и месту его хранения", "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": "Очистка тега", "theme_custom_css_settings": "Пользовательские CSS", "theme_custom_css_settings_description": "Каскадные таблицы стилей позволяют настраивать дизайн Immich.", "theme_settings": "Настройки темы", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Аппаратное ускорение", "transcoding_hardware_acceleration_description": "Экспериментальный; намного быстрее, но будет иметь более низкое качество при том же битрейте", "transcoding_hardware_decoding": "Аппаратное декодирование", - "transcoding_hardware_decoding_setting_description": "Применяется только к NVENC и RKMPP. Включает сквозное ускорение, а не только ускорение кодирования. Может работать не со всеми видео.", + "transcoding_hardware_decoding_setting_description": "Включает сквозное ускорение, а не только ускорение кодирования. Может работать не со всеми видео.", "transcoding_hevc_codec": "Кодек HEVC", "transcoding_max_b_frames": "Максимально промежуточных кадров", "transcoding_max_b_frames_description": "Более высокие значения повышают эффективность сжатия, но замедляют кодирование. Может быть несовместимо с аппаратным ускорением на старых устройствах. 0 отключает B-кадры, а -1 устанавливает это значение автоматически.", @@ -283,7 +304,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": "Временной AQ", @@ -298,20 +319,21 @@ "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": "Срок хранения", - "trash_number_of_days_description": "Количество дней, в течение которых объекты будут храниться в корзине, прежде чем они будут окончательно удалены", + "trash_number_of_days_description": "Количество дней, в течение которых файлы будут храниться в корзине до окончательного удаления", "trash_settings": "Настройки корзины", "trash_settings_description": "Управление настройками корзины", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_description": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", - "user_delete_delay": "Аккаунт и ресурсы пользователя {user} будут запланированы для окончательного удаления через {delay, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", + "user_cleanup_job": "Очистка пользователя", + "user_delete_delay": "Аккаунт и файлы пользователя {user} будут отложены до окончательного удаления через {delay, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", "user_delete_delay_settings": "Отложенное удаление", - "user_delete_delay_settings_description": "Срок в днях, по истечение которого происходит окончательное удаление учетной записи пользователя и его ресурсов после удаления учётной записи. Задача по удалению пользователей выполняется в полночь. Изменения этой настройки будут учтены при следующем запуске задачи.", - "user_delete_immediately": "Аккаунт и ресурсы пользователя {user} будут поставлены в очередь на немедленное окончательное удаление.", - "user_delete_immediately_checkbox": "Поставить пользователя и объекты в очередь для удаления", + "user_delete_delay_settings_description": "Срок в днях, по истечение которого происходит окончательное удаление учетной записи пользователя и его ресурсов. Задача по удалению пользователей выполняется в полночь. Изменения этой настройки будут учтены при следующем запуске задачи.", + "user_delete_immediately": "Аккаунт и файлы пользователя {user} будут немедленно поставлены в очередь для окончательного удаления.", + "user_delete_immediately_checkbox": "Поместить пользователя и его файлы в очередь для немедленного удаления", "user_management": "Управление пользователями", "user_password_has_been_reset": "Пароль пользователя был сброшен:", "user_password_reset_description": "Пожалуйста, предоставьте временный пароль пользователю и сообщите ему, что при следующем входе в систему пароль нужно будет изменить.", @@ -320,7 +342,8 @@ "user_settings": "Пользовательские настройки", "user_settings_description": "Управление настройками пользователей", "user_successfully_removed": "Пользователь {email} был успешно удален.", - "version_check_enabled_description": "Включить периодические запросы к GitHub для проверки наличия новых версий", + "version_check_enabled_description": "Включить проверку наличия новых версий", + "version_check_implications": "Функция проверки версии зависит от периодического взаимодействия с github.com", "version_check_settings": "Проверка версии", "version_check_settings_description": "Включить/отключить уведомление о новой версии", "video_conversion_job": "Перекодирование видео", @@ -336,7 +359,8 @@ "album_added": "Альбом добавлен", "album_added_notification_setting_description": "Получать уведомление по электронной почте, когда вы добавлены к общему альбому", "album_cover_updated": "Обложка альбома обновлена", - "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?\nЕсли этот альбом общий, то другие пользователи не смогут получить к нему доступ.", + "album_delete_confirmation": "Вы уверены, что хотите удалить альбом {album}?", + "album_delete_confirmation_description": "Если альбом был общим, другие пользователи больше не смогут получить к нему доступ.", "album_info_updated": "Информация об альбоме обновлена", "album_leave": "Покинуть альбом?", "album_leave_confirmation": "Вы уверены, что хотите покинуть {album}?", @@ -346,12 +370,12 @@ "album_remove_user_confirmation": "Вы уверены, что хотите удалить пользователя {user}?", "album_share_no_users": "Похоже, вы поделились этим альбомом со всеми пользователями или у вас нет пользователей, с которыми можно поделиться.", "album_updated": "Альбом обновлён", - "album_updated_setting_description": "Получать уведомление по электронной почте, когда в общий альбом добавлены новые ресурсы", + "album_updated_setting_description": "Получать уведомление по электронной почте при добавлении новых ресурсов в общий альбом", "album_user_left": "Вы покинули {album}", "album_user_removed": "Пользователь {user} удален", "album_with_link_access": "Поделитесь ссылкой на альбом, чтобы ваши друзья могли его посмотреть.", "albums": "Альбомы", - "albums_count": "{count, plural, one {Альбом ({count, number})} few {Альбома ({count, number})} many {Альбомов ({count, number})} other {Альбомов ({count, number})}}", + "albums_count": "{count, plural, one {{count, number} альбом} few {{count, number} альбома} many {{count, number} альбомов} other {{count, number} альбомов}}", "all": "Все", "all_albums": "Все альбомы", "all_people": "Все люди", @@ -360,12 +384,13 @@ "allow_edits": "Разрешить редактирование", "allow_public_user_to_download": "Разрешить скачивание публичным пользователям", "allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы", + "anti_clockwise": "Против часовой", "api_key": "API Ключ", "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", "api_key_empty": "Ваш API ключ не должен быть пустым", "api_keys": "Ключи API", "app_settings": "Параметры приложения", - "appears_in": "Появляется в", + "appears_in": "Добавлено в", "archive": "Архив", "archive_or_unarchive_photo": "Архивировать или разархивировать фото", "archive_size": "Размер архива", @@ -376,28 +401,29 @@ "are_you_sure_to_do_this": "Вы уверены, что хотите это сделать?", "asset_added_to_album": "Добавлено в альбом", "asset_adding_to_album": "Добавление в альбом...", - "asset_description_updated": "Описание ресурса было обновлено", + "asset_description_updated": "Описание обновлено", "asset_filename_is_offline": "Объект {filename} находится в офлайн-режиме", "asset_has_unassigned_faces": "Есть не распознанные лица", "asset_hashing": "Хеширование...", "asset_offline": "Объект отключён", - "asset_offline_description": "Этот объект находится в офлайн-режиме. Immich не может получить доступ к его расположению. Пожалуйста, убедитесь, что объект доступен, и затем пересканируйте библиотеку.", + "asset_offline_description": "Этот внешний файл не найден на диске. Пожалуйста, свяжитесь с администратором Immich для получения помощи.", "asset_skipped": "Пропущено", + "asset_skipped_in_trash": "В корзине", "asset_uploaded": "Загружено", "asset_uploading": "Загрузка...", "assets": "Объекты", "assets_added_count": "Добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", - "assets_added_to_album_count": "Добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}} в альбом", - "assets_added_to_name_count": "Добавлено {count, plural, one {# объект} other {# объектов}} в {hasName, select, true {{name}} other {новый альбом}}", + "assets_added_to_album_count": "В альбом добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}}", + "assets_added_to_name_count": "Добавлено {count, plural, one {# объект} few {# объекта} other {# объектов}} в {hasName, select, true {{name}} other {новый альбом}}", "assets_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}}", "assets_moved_to_trash": "Перемещено {count, plural, one {# объект} few {# объекта} many {# объектов} other {# объекта}} в корзину", "assets_moved_to_trash_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} перемещено в корзину", "assets_permanently_deleted_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} удалено навсегда", "assets_removed_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} удалено", - "assets_restore_confirmation": "Вы уверены, что хотите восстановить все удаленные объекты? Это действие нельзя отменить!", + "assets_restore_confirmation": "Вы уверены, что хотите восстановить все объекты из корзины? Это действие нельзя отменить! Обратите внимание, что любые оффлайн-объекты не могут быть восстановлены таким способом.", "assets_restored_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} восстановлено", "assets_trashed_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} перемещено в корзину", - "assets_were_part_of_album_count": "{count, plural, one {# Объект} other {# Объекты}} уже часть альбома", + "assets_were_part_of_album_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} уже в альбоме", "authorized_devices": "Разрешенные устройства", "back": "Назад", "back_close_deselect": "Назад, закрыть или отменить выбор", @@ -405,11 +431,12 @@ "birthdate_saved": "Дата рождения успешно сохранена", "birthdate_set_description": "Дата рождения используется для расчета возраста этого человека на момент фотографии.", "blurred_background": "Размытый фон", + "bugs_and_feature_requests": "Ошибки и запросы", "build": "Сборка", "build_image": "Версия сборки", - "bulk_delete_duplicates_confirmation": "Вы уверены, что хотите массово удалить {count, plural, one {# дублирующийся ресурс} other {# дублирующихся ресурсов}}? Это сохранит самый большой ресурс из каждой группы и навсегда удалит все остальные дубликаты. Это действие нельзя отменить!", - "bulk_keep_duplicates_confirmation": "Вы уверены, что хотите оставить {count, plural, one {# дублирующийся ресурс} other {# дублирующихся ресурсов}}? Это разрешит все группы дубликатов без удаления чего-либо.", - "bulk_trash_duplicates_confirmation": "Вы уверены, что хотите массово переместить в корзину {count, plural, one {# дублирующийся ресурс} other {# дублирующихся ресурсов}}? Это сохранит самый большой ресурс из каждой группы и переместит в корзину все остальные дубликаты.", + "bulk_delete_duplicates_confirmation": "Вы уверены, что хотите массово удалить {count, plural, one {# дублирующийся объект} other {# дублирующихся объектов}}? Это сохранит самый большой файл из каждой группы и навсегда удалит дубликаты. Это действие нельзя отменить!", + "bulk_keep_duplicates_confirmation": "Вы уверены, что хотите оставить {count, plural, one {# дублирующийся объект} other {# дублирующихся объектов}}? Это сохранит все дубликаты.", + "bulk_trash_duplicates_confirmation": "Вы уверены, что хотите массово переместить в корзину {count, plural, one {# дублирующийся объект} other {# дублирующихся объектов}}? Это сохранит самый большой файл из каждой группы и переместит дубликаты в корзину.", "buy": "Приобретение лицензии Immich", "camera": "Камера", "camera_brand": "Производитель", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Очистить все недавние результаты поиска", "clear_message": "Очистить сообщение", "clear_value": "Очистить значение", + "clockwise": "По часовой", "close": "Закрыть", "collapse": "Свернуть", "collapse_all": "Свернуть всё", + "color": "Цвет", "color_theme": "Цветовая тема", "comment_deleted": "Комментарий удалён", "comment_options": "Параметры комментариев", @@ -470,13 +499,15 @@ "covers": "Обложки", "create": "Создать", "create_album": "Создать альбом", - "create_library": "Создать Библиотеку", + "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": "Текущее устройство", @@ -499,24 +530,31 @@ "delete_key": "Удалить ключ", "delete_library": "Удалить библиотеку", "delete_link": "Удалить ссылку", - "delete_shared_link": "Удалить общую ссылку", + "delete_shared_link": "Удалить публичную ссылку", + "delete_tag": "Удалить тег", + "delete_tag_confirmation_prompt": "Вы уверены, что хотите удалить тег {tagName}?", "delete_user": "Удалить пользователя", - "deleted_shared_link": "Удалена публичная ссылка", + "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": "Предпочитать отображать исходную фотографию при просмотре ресурса, а не миниатюры, если исходный ресурс совместим с Интернетом. Это может привести к снижению скорости отображения фотографий.", + "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": "Загрузка", @@ -546,10 +584,15 @@ "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": "", "empty_album": "Пустой альбом", @@ -562,11 +605,11 @@ "error_loading_image": "Ошибка при загрузке изображения", "error_title": "Ошибка - Что-то пошло не так", "errors": { - "cannot_navigate_next_asset": "Невозможно перейти к следующему объекту", - "cannot_navigate_previous_asset": "Не удается перейти к предыдущему ресурсу", + "cannot_navigate_next_asset": "Не удалось перейти к следующему объекту", + "cannot_navigate_previous_asset": "Не удалось перейти к предыдущему ресурсу", "cant_apply_changes": "Не удается применить изменения", "cant_change_activity": "Не удается {enabled, select, true {отключить} other {включить}} активность", - "cant_change_asset_favorite": "Не удается изменить статус \"избранное\" для ресурса", + "cant_change_asset_favorite": "Не удалось изменить статус \"избранное\" для ресурса", "cant_change_metadata_assets_count": "Не удается изменить метаданные у {count, plural, one {# ресурса} few {# ресурсов} many {# ресурсов} other {# ресурсов}}", "cant_get_faces": "Не удается получить лица", "cant_get_number_of_comments": "Не удается получить количество комментариев", @@ -583,15 +626,15 @@ "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_create_shared_link": "Не удалось создать публичную ссылку", + "failed_to_edit_shared_link": "Не удалось изменить публичную ссылку", "failed_to_get_people": "Не удалось получить информацию о людях", "failed_to_load_asset": "Ошибка загрузки объекта", "failed_to_load_assets": "Ошибка загрузки объектов", "failed_to_load_people": "Не удалось загрузить людей", "failed_to_remove_product_key": "Не удалось удалить ключ продукта", - "failed_to_stack_assets": "Не удалось создать стек", - "failed_to_unstack_assets": "Не удалось разобрать стек", + "failed_to_stack_assets": "Не удалось сгруппировать объекты", + "failed_to_unstack_assets": "Не удалось разгруппировать объекты", "import_path_already_exists": "Этот путь импорта уже существует.", "incorrect_email_or_password": "Неверный адрес электронной почты или пароль", "paths_validation_failed": "{paths, plural, one {# путь} other {# путей}} не прошли проверку", @@ -599,13 +642,13 @@ "quota_higher_than_disk_size": "Вы установили квоту, превышающую размер диска", "repair_unable_to_check_items": "Невозможно проверить {count, select, one {элемент} other {элементы}}", "unable_to_add_album_users": "Невозможно добавить пользователей в альбом", - "unable_to_add_assets_to_shared_link": "Не удалось добавить ресурсы к общей ссылке", + "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 {удалить ресурс из} other {добавить ресурс в}} архив", - "unable_to_add_remove_favorites": "Не удалось {favorite, select, true {добавить ресурс в} other {удалить ресурс из}} избранного", + "unable_to_add_remove_archive": "Не удалось {archived, select, true {удалить ресурс из архива} other {добавить ресурс в архив}}", + "unable_to_add_remove_favorites": "Не удалось {favorite, select, true {добавить ресурс в избранное} other {удалить ресурс из избранного}}", "unable_to_archive_unarchive": "Не удалось {archived, select, true {архивировать} other {разархивировать}}", "unable_to_change_album_user_role": "Не удалось изменить роль пользователя в альбоме", "unable_to_change_date": "Невозможно изменить дату", @@ -624,11 +667,11 @@ "unable_to_create_library": "Не удалось создать библиотеку", "unable_to_create_user": "Не удалось создать пользователя", "unable_to_delete_album": "Не удается удалить альбом", - "unable_to_delete_asset": "Не удается удалить ресурс", + "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_shared_link": "Не удалось удалить публичную ссылку", "unable_to_delete_user": "Не удается удалить пользователя", "unable_to_download_files": "Невозможно скачать файлы", "unable_to_edit_exclusion_pattern": "Невозможно отредактировать шаблон исключения", @@ -637,11 +680,12 @@ "unable_to_enter_fullscreen": "Не удается войти в полноэкранный режим", "unable_to_exit_fullscreen": "Не удается выйти из полноэкранного режима", "unable_to_get_comments_number": "Не удалось получить количество комментариев", - "unable_to_get_shared_link": "Не удалось получить общую ссылку", + "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_asset_activity": "Не удалось загрузить комментарии", "unable_to_load_items": "Не удалось загрузить элементы", "unable_to_load_liked_status": "Невозможно загрузить статус лайка", "unable_to_log_out_all_devices": "Невозможно выйти из всех устройств", @@ -651,12 +695,12 @@ "unable_to_reassign_assets_existing_person": "Невозможно переназначить ресурсы на {name, select, null {существующего человека} other {{name}}}", "unable_to_reassign_assets_new_person": "Не удается переназначить ресурсы новому человеку", "unable_to_refresh_user": "Невозможно обновить пользователя", - "unable_to_remove_album_users": "Не удается удалить пользователей из альбома", + "unable_to_remove_album_users": "Не удалось удалить пользователей из альбома", "unable_to_remove_api_key": "Не удается удалить ключ API", - "unable_to_remove_assets_from_shared_link": "Невозможно удалить объекты из общей ссылки", + "unable_to_remove_assets_from_shared_link": "Невозможно удалить объекты из публичной ссылки", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Не удается удалить автономные файлы", "unable_to_remove_library": "Не удается удалить библиотеку", - "unable_to_remove_offline_files": "Не удается удалить автономные файлы", "unable_to_remove_partner": "Не удается удалить партнера", "unable_to_remove_reaction": "Не удается удалить реакцию", "unable_to_remove_user": "", @@ -677,9 +721,10 @@ "unable_to_set_feature_photo": "Не удалось установить фотографию на обложку", "unable_to_set_profile_picture": "Невозможно установить изображение профиля", "unable_to_submit_job": "Невозможно отправить задание", - "unable_to_trash_asset": "Невозможно удалить актив", + "unable_to_trash_asset": "Не удалось переместить объект в корзину", "unable_to_unlink_account": "Не удалось отсоединить учетную запись", - "unable_to_update_album_cover": "Невозможно обновить обложку альбома", + "unable_to_unlink_motion_video": "Не удается отсоединить движущееся видео", + "unable_to_update_album_cover": "Не удалось обновить обложку альбома", "unable_to_update_album_info": "Невозможно обновить информацию об альбоме", "unable_to_update_library": "Не удалось обновить библиотеку", "unable_to_update_location": "Не удалось обновить местоположение", @@ -698,7 +743,8 @@ "expire_after": "Истекает через", "expired": "Срок действия истек", "expires_date": "Срок действия до {date}", - "explore": "Просмотр", + "explore": "Поиск", + "explorer": "Проводник", "export": "Экспортировать", "export_as_json": "Экспорт в JSON", "extension": "Расширение", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Избранное фото обновлено", "featurecollection": "", + "features": "Дополнительные возможности", + "features_setting_description": "Управление дополнительными возможностями приложения", "file_name": "Имя файла", "file_name_or_extension": "Имя файла или расширение", "filename": "Имя файла", @@ -720,6 +768,8 @@ "filter_people": "Фильтр по людям", "find_them_fast": "Быстро найдите их по имени с помощью поиска", "fix_incorrect_match": "Исправить неправильное соответствие", + "folders": "Папки", + "folders_feature_description": "Просмотр папок с фотографиями и видео в файловой системе", "force_re-scan_library_files": "Принудительное повторное сканирование всех файлов библиотеки", "forward": "Переслать", "general": "Общие", @@ -765,7 +815,7 @@ "in_archive": "В архиве", "include_archived": "Отображать архив", "include_shared_albums": "Включать общие альбомы", - "include_shared_partner_assets": "Включать общие активы партнеров", + "include_shared_partner_assets": "Включать общие ресурсы партнера", "individual_share": "Персональный доступ", "info": "Информация", "interval": { @@ -784,7 +834,7 @@ "keyboard_shortcuts": "Сочетания клавиш", "language": "Язык", "language_setting_description": "Выберите предпочитаемый вами язык", - "last_seen": "Видели последний раз", + "last_seen": "Последний доступ осуществлялся", "latest_version": "Последняя Версия", "latitude": "Широта", "leave": "Покинуть", @@ -819,6 +869,7 @@ "license_trial_info_4": "Пожалуйста, рассмотрите возможность приобретения лицензии для поддержки дальнейшего развития проекта", "light": "Светлая", "like_deleted": "Лайк удален", + "link_motion_video": "Ссылка на движущееся видео", "link_options": "Настройки ссылки", "link_to_oauth": "Присоединение к OAuth", "linked_oauth_account": "Присоединённый аккаунт OAuth", @@ -837,13 +888,14 @@ "look": "Просмотр", "loop_videos": "Циклическое воспроизведение", "loop_videos_description": "Включить циклическое воспроизведение видео.", + "main_branch_warning": "Вы используете версию для разработки; мы настоятельно рекомендуем использовать релизную версию!", "make": "Производитель", - "manage_shared_links": "Управление общими ссылками", - "manage_sharing_with_partners": "Управление обменом информацией с партнерами", + "manage_shared_links": "Управление публичными ссылками", + "manage_sharing_with_partners": "Управление обменом информацией с партнерами. Эта функция позволяет вашему партнеру видеть ваши фотографии и видеозаписи, кроме тех, которые находятся в Архиве и Корзине", "manage_the_app_settings": "Управление настройками приложения", "manage_your_account": "Управление учётной записью", "manage_your_api_keys": "Управление API-ключами", - "manage_your_devices": "Управление устройствами, вошедшими в систему", + "manage_your_devices": "Управление устройствами, с помощью которых осуществлялся доступ к системе", "manage_your_oauth_connection": "Настройки подключённого OAuth", "map": "Карта", "map_marker_for_images": "Маркер на карте для изображений, сделанных в {city}, {country}", @@ -852,7 +904,7 @@ "matches": "Совпадения", "media_type": "Тип медиа", "memories": "Воспоминания", - "memories_setting_description": "Управляйте тем, что вы видите в своих воспоминаниях", + "memories_setting_description": "Управление тем, что вы видите в своих воспоминаниях", "memory": "Память", "memory_lane_title": "Воспоминание {title}", "menu": "Меню", @@ -864,9 +916,9 @@ "merged_people_count": "Объединено {count, plural, one {# человек} few {# человека} many {# человек} other {# человека}}", "minimize": "Минимизировать", "minute": "Минута", - "missing": "Отсутствующие", + "missing": "Пропущенные", "model": "Модель", - "month": "Месяцу", + "month": "Месяц", "more": "Больше", "moved_to_trash": "Перенесено в корзину", "my_albums": "Мои альбомы", @@ -899,25 +951,28 @@ "no_results_description": "Попробуйте использовать синоним или более общее ключевое слово", "no_shared_albums_message": "Создайте альбом для обмена фотографиями и видеозаписями с людьми в вашей сети", "not_in_any_album": "Ни в одном альбоме", - "note_apply_storage_label_to_previously_uploaded assets": "Примечание: Чтобы применить тег хранилища к ранее загруженным ресурсам запустите", + "note_apply_storage_label_to_previously_uploaded assets": "Примечание: Чтобы применить тег хранилища к ранее загруженным ресурсам, запустите", "note_unlimited_quota": "Примечание: Введите 0 для неограниченной квоты", - "notes": "Записки", + "notes": "Примечание", "notification_toggle_setting_description": "Включить уведомления по электронной почте", "notifications": "Уведомления", "notifications_setting_description": "Управление уведомлениями", "oauth": "OAuth", + "official_immich_resources": "Официальные ресурсы Immich", "offline": "Недоступен", "offline_paths": "Недоступные пути", "offline_paths_description": "Эти результаты могут быть вызваны ручным удалением файлов, которые не являются частью внешней библиотеки.", "ok": "ОК", "oldest_first": "Сначала старые", "onboarding": "Начало работы", + "onboarding_privacy_description": "Следующие (необязательные) функции зависят от внешних сервисов и могут быть отключены в любое время в настройках администрирования.", "onboarding_theme_description": "Выберите цветовую тему. Вы можете изменить ее позже в настройках.", "onboarding_welcome_description": "Давайте настроим ваш экземпляр с некоторыми общими параметрами.", "onboarding_welcome_user": "Добро пожаловать, {user}", "online": "Доступен", "only_favorites": "Только избранное", "only_refreshes_modified_files": "Обновляет только измененные файлы", + "open_in_map_view": "Открыть в режиме просмотра карты", "open_in_openstreetmap": "Открыть в OpenStreetMap", "open_the_search_filters": "Открыть фильтры поиска", "options": "Опции", @@ -931,7 +986,7 @@ "owner": "Владелец", "partner": "Партнер", "partner_can_access": "{partner} имеет доступ", - "partner_can_access_assets": "Все ваши фотографии и видеозаписи, кроме тех, которые находятся в Архиве и Удалены", + "partner_can_access_assets": "Все ваши фотографии и видеозаписи, кроме тех, которые находятся в Архиве и Корзине", "partner_can_access_location": "Местоположение, где были сделаны ваши фотографии", "partner_sharing": "Совместное использование", "partners": "Партнёры", @@ -952,22 +1007,23 @@ "pending": "Ожидает", "people": "Люди", "people_edits_count": "Изменено {count, plural, one {# человек} few {# человека} many {# людей} other {# человек}}", + "people_feature_description": "Просмотр фотографий и видео, сгруппированных по людям", "people_sidebar_description": "Отображать пункт меню \"Люди\" в боковой панели", "perform_library_tasks": "", "permanent_deletion_warning": "Предупреждение об удалении", - "permanent_deletion_warning_setting_description": "Отображать предупреждение при безвозвратном удалении ресурсов", + "permanent_deletion_warning_setting_description": "Предупреждать перед безвозвратным удалением ресурсов", "permanently_delete": "Удалить навсегда", - "permanently_delete_assets_count": "Полностью удалить {count, plural, one {ресурс} few {ресурса} many {ресурсов} other {ресурсов}}", + "permanently_delete_assets_count": "Безвозвратно удалить {count, plural, one {ресурс} few {ресурса} many {ресурсов} other {ресурсов}}", "permanently_delete_assets_prompt": "Вы действительно хотите навсегда удалить {count, plural, one {этот объект?} other {эти # объектов?}} Это так же удалит {count, plural, one {его} other {их}} из альбома(ов).", - "permanently_deleted_asset": "Удалить объект навсегда", + "permanently_deleted_asset": "Удалить навсегда", "permanently_deleted_assets": "Безвозвратно удалено {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}}", - "permanently_deleted_assets_count": "Полностью удалено {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}}", + "permanently_deleted_assets_count": "Безвозвратно удалено {count, plural, one {# файл} few {# файла} many {# файлов} other {# файлов}}", "person": "Человек", "person_hidden": "{name}{hidden, select, true { (скрыт)} other {}}", "photo_shared_all_users": "Похоже, что вы поделились своими фотографиями со всеми пользователями или у вас нет пользователей, с которыми можно поделиться.", "photos": "Фото", "photos_and_videos": "Фото и Видео", - "photos_count": "{count, plural, one {Фотография ({count, number})} few {Фотографии ({count, number})} many {Фотографий ({count, number})} other {Фотографий ({count, number})}}", + "photos_count": "{count, plural, one {{count, number} Фотография} few {{count, number} Фотографии} many {{count, number} Фотографий} other {{count, number} Фотографий}}", "photos_from_previous_years": "Фотографии прошлых лет в этот день", "pick_a_location": "Выбрать местоположение", "place": "Места", @@ -984,6 +1040,7 @@ "previous_memory": "Предыдущее воспоминание", "previous_or_next_photo": "Предыдущая или следующая фотография", "primary": "Главное", + "privacy": "Конфиденциальность", "profile_image_of_user": "Изображение профиля {user}", "profile_picture_set": "Установлена картинка профиля.", "public_album": "Публичный альбом", @@ -1021,42 +1078,49 @@ "purchase_server_title": "Сервер", "purchase_settings_server_activated": "Ключ продукта сервера управляется администратором", "range": "", + "rating": "Рейтинг звёзд", + "rating_clear": "Очистить рейтинг", + "rating_count": "{count, plural, one {# звезда} other {# звезд}}", + "rating_description": "Показывать рейтинг в панели информации", "raw": "", "reaction_options": "Опции реакций", "read_changelog": "Прочитать список изменений", "reassign": "Переназначить", - "reassigned_assets_to_existing_person": "Переназначено {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}} на {name, select, null {существующего человека} other {{name}}}", - "reassigned_assets_to_new_person": "Переназначено {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}} новому человеку", - "reassing_hint": "Назначить выбранные ресурсы существующему человеку", + "reassigned_assets_to_existing_person": "Переназначен{count, plural, one { # ресурс} few {о # ресурса} many {о # ресурсов} other {о # ресурсов}} на {name, select, null {существующего пользователя} other {{name}}}", + "reassigned_assets_to_new_person": "Переназначен{count, plural, one { # ресурс} few {о # ресурса} many {о # ресурсов} other {о # ресурсов}} новому человеку", + "reassing_hint": "Назначить выбранные ресурсы существующему пользователю", "recent": "Недавние", "recent_searches": "Недавние поисковые запросы", "refresh": "Обновить", "refresh_encoded_videos": "Обновить закодированные видео", + "refresh_faces": "Обновить лица", "refresh_metadata": "Обновить метаданные", "refresh_thumbnails": "Обновить миниатюры", "refreshed": "Обновлено", - "refreshes_every_file": "Обновляет каждый файл", + "refreshes_every_file": "Обновляет все существующие и новые файлы", "refreshing_encoded_video": "Обновление закодированного видео", + "refreshing_faces": "Обновление лиц", "refreshing_metadata": "Обновление метаданных", "regenerating_thumbnails": "Восстановление миниатюр", "remove": "Удалить", - "remove_assets_album_confirmation": "Вы уверены, что хотите удалить {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}} из альбома?", - "remove_assets_shared_link_confirmation": "Вы уверены, что хотите удалить {count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурса}} из этой общей ссылки?", + "remove_assets_album_confirmation": "Вы уверены, что хотите удалить {count, plural, one {# объект} few {# объекта} many {# объектов} other {# объектов}} из альбома?", + "remove_assets_shared_link_confirmation": "Вы уверены, что хотите удалить {count, plural, one {# объект} few {# объекта} many {# объектов} other {# объектов}} из этой публичной ссылки?", "remove_assets_title": "Удалить объекты?", "remove_custom_date_range": "Удалить пользовательский диапазон дат", + "remove_deleted_assets": "Удаление автономных файлов", "remove_from_album": "Удалить из альбома", "remove_from_favorites": "Удалить из избранного", - "remove_from_shared_link": "Удалить из общей ссылки", - "remove_offline_files": "Удаление автономных файлов", + "remove_from_shared_link": "Удалить из публичной ссылки", "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": "Загрузить и заменить здесь", + "replace_with_upload": "Загрузить и заменить", "repository": "Репозиторий", "require_password": "Требуется пароль", "require_user_to_change_password_on_first_login": "Требовать у пользователя сменить пароль при первом входе", @@ -1084,6 +1148,7 @@ "say_something": "Скажите что-нибудь", "scan_all_libraries": "Сканировать все библиотеки", "scan_all_library_files": "Повторное сканирование всех файлов библиотеки", + "scan_library": "Сканировать", "scan_new_library_files": "Сканировать новые файлы в библиотеке", "scan_settings": "Настройки сканирования", "scanning_for_album": "Сканирование альбома...", @@ -1099,9 +1164,12 @@ "search_for_existing_person": "Поиск существующего человека", "search_no_people": "Нет людей", "search_no_people_named": "Нет людей с именем \"{name}\"", + "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", + "search_settings": "Настройки поиска", "search_state": "Поиск региона...", + "search_tags": "Поиск по тегам...", "search_timezone": "Поиск часового пояса...", "search_type": "Тип поиска", "search_your_photos": "Поиск фотографий", @@ -1131,7 +1199,7 @@ "server_version": "Версия Сервера", "set": "Установить", "set_as_album_cover": "Установить в качестве обложки альбома", - "set_as_profile_picture": "Установить в качестве изображения профиля", + "set_as_profile_picture": "Установить как фото профиля", "set_date_of_birth": "Установить дату рождения", "set_profile_picture": "Установить изображение профиля", "set_slideshow_to_fullscreen": "Переведите слайд-шоу в полноэкранный режим", @@ -1143,14 +1211,16 @@ "shared_by_user": "Владелец: {user}", "shared_by_you": "Вы поделились", "shared_from_partner": "Фото от {partner}", - "shared_links": "Общие ссылки", - "shared_photos_and_videos_count": "{assetCount, plural, other {# поделился фото и видео.}}", + "shared_link_options": "Параметры публичных ссылок", + "shared_links": "Публичные ссылки", + "shared_photos_and_videos_count": "{assetCount, plural, other {# фото и видео.}}", "shared_with_partner": "Совместно с {partner}", "sharing": "Общие", "sharing_enter_password": "Пожалуйста, введите пароль для просмотра этой страницы.", "sharing_sidebar_description": "Отображать пункт меню \"Общие\" в боковой панели", "shift_to_permanent_delete": "нажмите ⇧ чтобы удалить объект навсегда", "show_album_options": "Показать параметры альбома", + "show_albums": "Показать альбомы", "show_all_people": "Показать всех людей", "show_and_hide_people": "Показать и скрыть людей", "show_file_location": "Показать расположение файла", @@ -1165,13 +1235,18 @@ "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": "Сортировать альбомы по...", @@ -1182,7 +1257,9 @@ "sort_recent": "Недавние фото", "sort_title": "Заголовок", "source": "Источник", - "stack": "Стек", + "stack": "В стопку", + "stack_duplicates": "Стек дубликатов", + "stack_select_one_photo": "Выберите одну главную фотографию для стека", "stack_selected_photos": "Сложить выбранные фотографии в стопку", "stacked_assets_count": "{count, plural, one {# объект добавлен} few {# объекта добавлено} other {# объектов добавлено}} в стек", "stacktrace": "Трассировка стека", @@ -1200,19 +1277,33 @@ "submit": "Подтвердить", "suggestions": "Предложения", "sunrise_on_the_beach": "Восход солнца на пляже", + "support": "Поддержка", + "support_and_feedback": "Поддержка и обратная связь", + "support_third_party_description": "Ваша установка immich была упакована сторонним разработчиком. Проблемы, с которыми вы столкнулись, могут быть вызваны этим пакетом, поэтому, пожалуйста, в первую очередь обращайтесь к ним, используя ссылки ниже.", "swap_merge_direction": "Изменить направление слияния", - "sync": "Синхронизировать", + "sync": "Синхр.", + "tag": "Тег", + "tag_assets": "Добавить теги", + "tag_created": "Тег {tag} создан", + "tag_feature_description": "Просмотр фотографий и видео, сгруппированных по тегам", + "tag_not_found_question": "Не удается найти тег? Создайте новый тег.", + "tag_updated": "Тег {tag} изменен", + "tagged_assets": "Помечено {count, plural, one {# объект} other {# объектов}}", + "tags": "Теги", "template": "Шаблон", "theme": "Тема", "theme_selection": "Выбор темы", "theme_selection_description": "Автоматически устанавливать тему в зависимости от системных настроек вашего браузера", "they_will_be_merged_together": "Они будут объединены вместе", + "third_party_resources": "Сторонние ресурсы", "time_based_memories": "Воспоминания, основанные на времени", "timezone": "Часовой пояс", "to_archive": "В архив", "to_change_password": "Изменить пароль", "to_favorite": "Добавить в избранное", "to_login": "Вход", + "to_parent": "Вернуться назад", + "to_root": "В начало", "to_trash": "Корзина", "toggle_settings": "Переключение настроек", "toggle_theme": "Переключение темы", @@ -1221,7 +1312,7 @@ "trash": "Корзина", "trash_all": "Удалить всё", "trash_count": "Удалить {count, number}", - "trash_delete_asset": "Удалить ресурс", + "trash_delete_asset": "Переместить в корзину", "trash_no_results_message": "Здесь будут отображаться удалённые фотографии и видео.", "trashed_items_will_be_permanently_deleted_after": "Элементы в корзине будут автоматически удалены через {days, plural, one {# день} other {# дней}}.", "type": "Тип", @@ -1234,24 +1325,26 @@ "unknown_album": "Неизвестный альбом", "unknown_year": "Неизвестный Год", "unlimited": "Не ограничено", + "unlink_motion_video": "Отсоединить движущееся видео", "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", "unnamed_album": "Альбом без названия", + "unnamed_album_delete_confirmation": "Вы уверены, что хотите удалить этот альбом?", "unnamed_share": "Общий доступ без названия", "unsaved_change": "Не сохраненное изменение", "unselect_all": "Снять всё", "unselect_all_duplicates": "Отменить выбор всех дубликатов", "unstack": "Разобрать стек", - "unstacked_assets_count": "{count, plural, one {# объект} few {# объекта} other {# объектов}} разобрано из стека", + "unstacked_assets_count": "{count, plural, one {# объект извлечен} few {# объекта извлечено} other {# объектов извлечено}} из стека", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_decription": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", "up_next": "Следующее", "updated_password": "Пароль обновлён", "upload": "Загрузить", "upload_concurrency": "Параллельность загрузки", - "upload_errors": "Загрузка завершена с {count, plural, one {# ошибкой} few {# ошибками} many {# ошибками} other {# ошибками}}, обновите страницу, чтобы увидеть новые загруженные ресурсы.", + "upload_errors": "Загрузка завершена с {count, plural, one {# ошибкой} other {# ошибками}}, обновите страницу, чтобы увидеть новые загруженные ресурсы.", "upload_progress": "Осталось {remaining, number} - Обработано {processed, number}/{total, number}", - "upload_skipped_duplicates": "Пропущено {count, plural, one {# дублирующийся ресурс} few {# дублирующихся ресурса} many {# дублирующихся ресурсов} other {# дублирующихся ресурса}}", + "upload_skipped_duplicates": "Пропущен{count, plural, one { # дублирующийся ресурс} few {о # дублирующихся ресурса} many {о # дублирующихся ресурсов} other {о # дублирующихся ресурса}}", "upload_status_duplicates": "Дубликаты", "upload_status_errors": "Ошибки", "upload_status_uploaded": "Загружено", @@ -1275,30 +1368,33 @@ "variables": "Переменные", "version": "Версия", "version_announcement_closing": "Твой друг Алекс", - "version_announcement_message": "Привет, друг! В приложении доступна новая версия. Пожалуйста, посетите заметки к выпуску и убедитесь, что ваша настройка docker-compose.yml и .env актуальна, чтобы избежать ошибок конфигурации, особенно если вы используете WatchTower или другой механизм автоматического обновления вашего приложения.", + "version_announcement_message": "Привет, друг! Доступна новая версия приложения. Пожалуйста, посетите заметки к выпуску и убедитесь, что ваши параметры docker-compose.yml и .env актуальны, чтобы избежать ошибок конфигурации, особенно если вы используете WatchTower или другой механизм автоматического обновления приложения.", + "version_history": "История версий", + "version_history_item": "Версия {version} установлена {date}", "video": "Видео", "video_hover_setting": "Воспроизведение миниатюры видео при наведении курсора мыши", "video_hover_setting_description": "Воспроизводить миниатюры видео при наведении курсора мыши на объект. Даже если этот параметр отключен, воспроизведение можно запустить, наведя курсор на значок воспроизведения.", "videos": "Видео", - "videos_count": "{count, plural, one {Видео (#)} few {Видео (#)} many {Видео (#)} other {Видео (#)}}", + "videos_count": "{count, plural, one {# Видео} few {# Видео} many {# Видео} other {# Видео}}", "view": "Просмотр", "view_album": "Просмотреть альбом", "view_all": "Посмотреть всё", "view_all_users": "Показать всех пользователей", - "view_links": "Посмотреть ссылки", - "view_next_asset": "Посмотреть следующий объект", - "view_previous_asset": "Посмотреть предыдущий объект", - "view_stack": "Просмотреть Стек", + "view_in_timeline": "Показать на временной шкале", + "view_links": "Показать ссылки", + "view_next_asset": "Показать следующий объект", + "view_previous_asset": "Показать предыдущий объект", + "view_stack": "Показать стек", "viewer": "Наблюдатель", "visibility_changed": "Видимость изменена для {count, plural, one {# человека} other {# людей}}", "waiting": "В очереди", "warning": "Предупреждение", "week": "Неделя", "welcome": "Добро пожаловать", - "welcome_to_immich": "Добро пожаловать в immich", + "welcome_to_immich": "Добро пожаловать в Immich", "year": "Год", "years_ago": "{years, plural, one {# год} few {# года} many {# лет} other {# года}} назад", "yes": "Да", - "you_dont_have_any_shared_links": "У вас нет общих ссылок", - "zoom_image": "Увеличить" + "you_dont_have_any_shared_links": "У вас нет публичных ссылок", + "zoom_image": "Приблизить" } diff --git a/i18n/sk.json b/i18n/sk.json new file mode 100644 index 0000000000..975229b51d --- /dev/null +++ b/i18n/sk.json @@ -0,0 +1,964 @@ +{ + "about": "O aplikácií", + "account": "Účet", + "account_settings": "Nastavenia účtu", + "acknowledge": "Potvrdiť", + "action": "Akcia", + "actions": "Akcie", + "active": "Aktívny", + "activity": "Aktivita", + "activity_changed": "Aktivita je {enabled, select, true{povolená} other {vypnutá}}", + "add": "Pridať", + "add_a_description": "Pridať popis", + "add_a_location": "Pridať polohu", + "add_a_name": "Pridať meno", + "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_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_album": "Pridať do albumu", + "add_to_shared_album": "Pridať do zdieľaného albumu", + "added_to_archive": "Pridané do archívu", + "added_to_favorites": "Pridané do obľúbených", + "added_to_favorites_count": "Pridané {count, number} do obľúbených", + "admin": { + "add_exclusion_pattern_description": "Pridávanie vzorov na vylúčenie. Globovanie pomocou *, ** a ? je podporované. Ak chcete ignorovať všetky súbory v akomkoľvek adresári s názvom \"Raw\", použite \"**/Raw/**\". Ak chcete ignorovať všetky súbory končiace na \".tif\", použite \"**/*.tif\". Ak chcete ignorovať absolútnu cestu, použite príkaz \"/cesta/k/ignorovanym/**\".", + "asset_offline_description": "Táto položka externej knižnice sa už na disku nenachádza a bola presunutá do koša. Pokiaľ bol súbor presunutý v rámci knižnice, skontrolujte časovú os a vyhľadajte nové odpovedajúce položky. Ak chcete túto položku obnoviť, uistite sa, že je cesta k nižšie uvedenému súboru prístupná pre aplikáciu Immich a prehľadajte knižnicu.", + "authentication_settings": "Nastavenia overovania", + "authentication_settings_description": "Spravovať heslo, protokol OAuth a ďalšie nastavenia overenia", + "authentication_settings_disable_all": "Naozaj chcete zakázať všetky spôsoby prihlásenia? Prihlásenie bude úplne zakázané.", + "authentication_settings_reenable": "Pre opätovné povolenie použite Serverový príkaz.", + "background_task_job": "Úlohy na pozadí", + "check_all": "Skontrolovať všetko", + "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}?", + "confirm_email_below": "Pre potvrdenie zadajte \"{email}\" nižšie", + "confirm_reprocess_all_faces": "Naozaj chcete spracovať všetky tváre znova? Tento proces vymaže pomenovaných ľudí.", + "confirm_user_password_reset": "Naozaj chcete resetovať heslo pre {user}?", + "create_job": "Vytvoriť úlohu", + "crontab_guru": "", + "disable_login": "Zakázať prihlásenie", + "disabled": "", + "duplicate_detection_job_description": "Spustiť strojové učenie na položkách pre detekciu podobných obrázkov. Spolieha sa na inteligentné vyhľadávanie", + "exclusion_pattern_description": "Vylučovacie vzory Vám umožňujú ignorovať súbory a priečinky pri skenovaní Vašej knižnice. Toto je užitočné, ak máte priečinky obsahujúce súbory, ktoré nechcete importovať, napríklad RAW súbory.", + "external_library_created_at": "Externá knižnica (vytvorená {date})", + "external_library_management": "Správa Externej Knižnice", + "face_detection": "Detekcia tvárí", + "face_detection_description": "Detekujte tváre v položkách pomocou strojového učenia. Pri videách sa berie do úvahy iba miniatúra. „Obnoviť“ znovu spracuje všetky položky. „Resetovať“ navyše vymaže všetky aktuálne údaje o tvárach. „Chýbajúce“ zaradí položky, ktoré ešte neboli spracované. Detekované tváre budú zaradené na rozpoznávanie tvárí po dokončení detekcie tvárí, pričom sa zoskupia do existujúcich alebo nových osôb.", + "facial_recognition_job_description": "Zoskupovať detekované tváre do osôb. Tento krok sa vykoná po dokončení detekcie tvárí. „Resetovať“ (znovu) zoskupí všetky tváre. „Chýbajúce“ zaradí tváre, ktoré nemajú pridelenú osobu.", + "failed_job_command": "Príkaz {command} zlyhal pre úlohu: {job}", + "force_delete_user_warning": "VAROVANIE: Toto okamžite odstráni používateľa a všetky položky. Tento krok nie je možné vrátiť späť a súbory nebude možné obnoviť.", + "forcing_refresh_library_files": "Vynútenie obnovy všetkých súborov knižnice", + "image_format": "Formát", + "image_format_description": "WebP vytvára menšie súbory ako JPEG, ale kódovanie je pomalšie.", + "image_prefer_embedded_preview": "Uprednostňovať vstavaný náhľad", + "image_prefer_embedded_preview_setting_description": "Použiť vložené náhľady vo fotografiách RAW ako vstup pre spracovanie obrazu, ak sú k dispozícii. To môže vytvoriť presnejšie farby pre niektoré obrázky, ale kvalita náhľadu závisí od fotoaparátu a obrázok môže mať viac kompresných artefaktov.", + "image_prefer_wide_gamut": "Uprednostňovať široký farebný rozsah", + "image_prefer_wide_gamut_setting_description": "Použiť Display P3 pre miniatúry. Toto lepšie zachováva živosť obrázkov so širokým farebným rozsahom. Obrázky sa môžu zobraziť odlišne na starších zariadeniach so starou verziou prehliadača. sRGB obrázky zostávajú sRGB, aby sa zabránilo farebným posunom.", + "image_preview_description": "Stredne veľký obrázok s odstránenými metadátami, používaný pri prezeraní jednej položky a na strojové učenie", + "image_preview_format": "Formát ukážky", + "image_preview_quality_description": "Kvalita náhľadu v stupnici od 1 do 100. Vyššia hodnota znamená lepšiu kvalitu, ale produkuje väčšie súbory a môže znížiť odozvu aplikácie. Nastavenie nižšej hodnoty môže ovplyvniť kvalitu strojového učenia.", + "image_preview_resolution": "Rozlíšenie náhľadu", + "image_preview_resolution_description": "Používa sa pri prezeraní jednej fotografie a pre strojové učenie. Vyššie rozlíšenie zachová viac detailov, ale kódovanie trvá dlhšie, súbory sú väčšie, a môže znížiť rýchlosť aplikácie.", + "image_preview_title": "Nastavenia Náhľadov", + "image_quality": "Kvalita", + "image_quality_description": "", + "image_resolution": "Rozlíšenie", + "image_resolution_description": "Vyššie rozlíšenie môže zachovať viac detailov, ale kódovanie trvá dlhšie, súbory sú väčšie a môže to znížiť rýchlosť odozvy aplikácie.", + "image_settings": "Nastavenia Obrázkov", + "image_settings_description": "Spravovať kvalitu a rozlíšenie generovaných obrázkov", + "image_thumbnail_description": "Malá miniatúra s odstránenými metadátami, používané pri zobrazovaní skupín fotiek ako na hlavnej časovej osi", + "image_thumbnail_format": "Formát náhľadu", + "image_thumbnail_quality_description": "Kvalita miniatúry v stupnici od 1 do 100. Vyššia hodnota znamená lepšiu kvalitu, ale produkuje väčšie súbory a môže znížiť odozvu aplikácie.", + "image_thumbnail_resolution": "", + "image_thumbnail_resolution_description": "", + "image_thumbnail_title": "Nastavenia miniatúr", + "job_concurrency": "Súbežnosť úlohy - {job}", + "job_created": "Úloha bola vytvorená", + "job_not_concurrency_safe": "Táto úloha nie je bezpečná pre súbežné spracovanie", + "job_settings": "Nastavenia Úloh", + "job_settings_description": "Spravovať súbežnosť úloh", + "job_status": "Stav Úloh", + "jobs_delayed": "{jobCount, plural, one {# oneskorený} few {# oneskorené} other {# oneskorených}}", + "jobs_failed": "{jobCount, plural, one {# neúspešný} few {# neúspešné} other {# neúspešných}}", + "library_created": "Vytvorená knižnica: {library}", + "library_cron_expression": "Výraz pre Cron", + "library_cron_expression_description": "Nastaviť skenovací interval pomocou formátu cron. Viac informácií nájdete napr. na Crontab Guru", + "library_cron_expression_presets": "Predvoľby výrazu pre Cron", + "library_deleted": "Knižnica bola vymazaná", + "library_import_path_description": "Zvoľte priečinok na importovanie. Tento priečinok vrátane podpriečinkov bude skenovaný pre obrázky a videá.", + "library_scanning": "Pravidelné skenovanie", + "library_scanning_description": "Nastaviť pravidelné skenovanie knižnice", + "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_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", + "logging_enable_description": "Povoliť zaznamenávanie", + "logging_level_description": "Ak je povolené, akú úroveň zaznamenávania použiť.", + "logging_settings": "Zaznamenávanie", + "machine_learning_clip_model": "Model CLIP", + "machine_learning_clip_model_description": "Názov modelu CLIP je uvedený tu. Pamätajte, že pri zmene modelu je nutné znovu spustiť úlohu 'Inteligentné vyhľadávanie' pre všetky obrázky.", + "machine_learning_duplicate_detection": "Detekcia duplikátov", + "machine_learning_duplicate_detection_enabled": "Povoliť detekciu duplikátov", + "machine_learning_duplicate_detection_enabled_description": "Ak je vypnuté, presne identické položky budú stále deduplikované.", + "machine_learning_duplicate_detection_setting_description": "Použiť CLIP embeddings na identifikáciu pravdepodobných duplikátov", + "machine_learning_enabled": "Povoliť strojové učenie", + "machine_learning_enabled_description": "Ak je vypnuté, všetky funkcie strojového učenia (ML) budú vypnuté, bez ohľadu na nastavenia nižšie.", + "machine_learning_facial_recognition": "Rozpoznávanie tvárí", + "machine_learning_facial_recognition_description": "Detekovať, rozpoznať a zoskupiť tváre na obrázkoch", + "machine_learning_facial_recognition_model": "Model pre rozpoznávanie tvárí", + "machine_learning_facial_recognition_model_description": "Modely sú zoradené od najväčšieho po najmenší. Väčšie modely sú pomalšie a vyžadujú viac pamäte, ale poskytujú lepšie výsledky. Pamätajte, že po zmene modelu je potrebné znovu spustiť úlohu detekcie tvárí pre všetky obrázky.", + "machine_learning_facial_recognition_setting": "Povoliť rozpoznávanie tvárí", + "machine_learning_facial_recognition_setting_description": "Ak je vypnuté, obrázky nebudú spracované pre rozpoznávanie tvárí a nebudú sa zobrazovať v sekcii Ľudia na stránke Preskúmať.", + "machine_learning_max_detection_distance": "Maximálna detekčná odchylka", + "machine_learning_max_detection_distance_description": "Maximálna odchylka medzi dvoma obrázkami, aby boli považované za duplikáty, v rozsahu od 0.001 do 0.1. Vyššie hodnoty odhalia viac duplikátov, ale môžu viesť k falošným pozitívam.", + "machine_learning_max_recognition_distance": "Maximálna rozpoznávacia odchylka", + "machine_learning_max_recognition_distance_description": "Maximálna odchylka medzi dvoma tvárami, aby boli považované za rovnakú osobu, v rozsahu od 0 do 2. Zníženie tejto hodnoty môže zabrániť označeniu dvoch ľudí za tú istú osobu, zatiaľ čo zvýšenie môže zabrániť označeniu jednej osoby za dve rôzne osoby. Pamätajte, že je jednoduchšie spojiť dvoch ľudí ako rozdeliť jednu osobu na dve, takže je lepšie voliť nižší prah, ak je to možné.", + "machine_learning_min_detection_score": "Minimálne detekčné skóre", + "machine_learning_min_detection_score_description": "Minimálne skóre dôveryhodnosti pre detekciu tváre v rozsahu od 0 do 1. Nižšie hodnoty odhalia viac tvárí, ale môžu viesť k falošným pozitivním výsledkom.", + "machine_learning_min_recognized_faces": "Minimum rozpoznaných tvárí", + "machine_learning_min_recognized_faces_description": "Minimálny počet rozpoznaných tvárí potrebných na vytvorenie osoby. Zvýšením tejto hodnoty sa zvyšuje presnosť rozpoznávania tvárí, ale tiež sa zvyšuje pravdepodobnosť, že tvár nebude priradená osobe.", + "machine_learning_settings": "Nastavenia strojového učenia", + "machine_learning_settings_description": "Spravovať funkcie a nastavenia strojového učenia", + "machine_learning_smart_search": "Inteligentné vyhľadávanie", + "machine_learning_smart_search_description": "", + "machine_learning_smart_search_enabled": "Povoliť inteligentné vyhľadávanie", + "machine_learning_smart_search_enabled_description": "", + "machine_learning_url_description": "URL adresa servera pre strojové učenie", + "manage_log_settings": "Spravovať nastavenia logovania", + "map_dark_style": "Tmavý štýl", + "map_enable_description": "Povoliť funkcie mapy", + "map_gps_settings": "Nastavenia Mapy & GPS", + "map_light_style": "Svetlý štýl", + "map_reverse_geocoding": "", + "map_reverse_geocoding_enable_description": "Povoliť reverzné geokódovanie", + "map_reverse_geocoding_settings": "", + "map_settings": "Mapa", + "map_settings_description": "Spravovať nastavenia mapy", + "map_style_description": "", + "metadata_extraction_job": "Extrahovať metadáta", + "metadata_extraction_job_description": "", + "metadata_faces_import_setting": "Povoliť import tváre", + "metadata_settings": "Nastavenia metadát", + "metadata_settings_description": "Spravovať nastavenia metadát", + "migration_job": "Migrácia", + "migration_job_description": "", + "notification_email_from_address": "Z adresy", + "notification_email_from_address_description": "", + "notification_email_host_description": "", + "notification_email_ignore_certificate_errors": "Ignorovať chyby certifikátu", + "notification_email_ignore_certificate_errors_description": "", + "notification_email_password_description": "", + "notification_email_port_description": "Porty e-mailového servera (napr. 25, 465, alebo 587)", + "notification_email_sent_test_email_button": "Odoslať testovací e-mail a uložiť", + "notification_email_setting_description": "Nastavenie pre odosielanie e-mailových upozornení", + "notification_email_test_email": "Odoslať testovací email", + "notification_email_test_email_failed": "Odosielanie testovacieho e-mailu zlyhalo, skontrolujte hodnoty", + "notification_email_test_email_sent": "Testovací e-mail bol odoslaný na adresu {email}. Prosím skontrolujte si Doručenú poštu.", + "notification_email_username_description": "Používateľské meno, ktoré sa má použiť pri overovaní s e-mailovým serverom", + "notification_enable_email_notifications": "Povoliť e-mailové upozornenia", + "notification_settings": "Nastavenia upozornení", + "notification_settings_description": "Spravovať nastavenia upozornení, vrátane emailu", + "oauth_auto_launch": "", + "oauth_auto_launch_description": "", + "oauth_auto_register": "", + "oauth_auto_register_description": "", + "oauth_button_text": "", + "oauth_client_id": "Client ID", + "oauth_client_secret": "Client Secret", + "oauth_enable_description": "Prihlásiť sa pomocou OAuth", + "oauth_issuer_url": "", + "oauth_mobile_redirect_uri": "", + "oauth_mobile_redirect_uri_override": "", + "oauth_mobile_redirect_uri_override_description": "", + "oauth_scope": "", + "oauth_settings": "OAuth", + "oauth_settings_description": "Spravovať nastavenia prihlásenia OAuth", + "oauth_signing_algorithm": "", + "oauth_storage_label_claim": "", + "oauth_storage_label_claim_description": "", + "oauth_storage_quota_claim": "", + "oauth_storage_quota_claim_description": "", + "oauth_storage_quota_default": "", + "oauth_storage_quota_default_description": "", + "password_enable_description": "Prihlásiť sa pomocou emailu a hesla", + "password_settings": "Prihlásenie cez heslo", + "password_settings_description": "Spravovať nastavenia prihlásenia cez heslo", + "refreshing_all_libraries": "Obnovujú sa všetky knižnice", + "registration": "Registrácia administrátora", + "repair_all": "Opraviť Všetko", + "require_password_change_on_login": "Vyžadovať od používateľa zmenu hesla pri prvom prihlásení", + "reset_settings_to_default": "Obnoviť pôvodné nastavenia", + "scanning_library": "Knižnica sa skenuje", + "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": "", + "server_settings": "Nastavenia servera", + "server_settings_description": "Spravovať nastavenia servera", + "server_welcome_message": "Uvítacia správa", + "server_welcome_message_description": "Správa, ktorá sa zobrazí na prihlasovacej stránke.", + "sidecar_job_description": "", + "slideshow_duration_description": "", + "smart_search_job_description": "", + "storage_template_enable_description": "", + "storage_template_hash_verification_enabled": "", + "storage_template_hash_verification_enabled_description": "", + "storage_template_migration_job": "", + "storage_template_settings": "", + "storage_template_settings_description": "", + "system_settings": "Nastavenia systému", + "theme_custom_css_settings": "Vlastné CSS", + "theme_custom_css_settings_description": "", + "theme_settings": "Nastavenia témovania", + "theme_settings_description": "Spravovať prispôsobenie webového rozhrania Immich", + "thumbnail_generation_job": "Generovať Miniatúry", + "thumbnail_generation_job_description": "", + "transcode_policy_description": "", + "transcoding_acceleration_api": "", + "transcoding_acceleration_api_description": "", + "transcoding_acceleration_nvenc": "NVENC (vyžaduje grafickú kartu NVIDIA)", + "transcoding_acceleration_qsv": "Quick Sync (vyžaduje 7. generáciu Intel procesora alebo novšie)", + "transcoding_acceleration_rkmpp": "", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "", + "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_video_codecs": "", + "transcoding_accepted_video_codecs_description": "", + "transcoding_advanced_options_description": "Možnosti, ktoré by väčšina používateľov nemala meniť", + "transcoding_audio_codec": "", + "transcoding_audio_codec_description": "", + "transcoding_bitrate_description": "", + "transcoding_constant_quality_mode": "", + "transcoding_constant_quality_mode_description": "", + "transcoding_constant_rate_factor": "", + "transcoding_constant_rate_factor_description": "", + "transcoding_disabled_description": "", + "transcoding_hardware_acceleration": "Hardvérová akcelerácia", + "transcoding_hardware_acceleration_description": "", + "transcoding_hardware_decoding": "", + "transcoding_hardware_decoding_setting_description": "", + "transcoding_hevc_codec": "", + "transcoding_max_b_frames": "", + "transcoding_max_b_frames_description": "", + "transcoding_max_bitrate": "", + "transcoding_max_bitrate_description": "", + "transcoding_max_keyframe_interval": "", + "transcoding_max_keyframe_interval_description": "", + "transcoding_optimal_description": "", + "transcoding_preferred_hardware_device": "", + "transcoding_preferred_hardware_device_description": "", + "transcoding_preset_preset": "", + "transcoding_preset_preset_description": "", + "transcoding_reference_frames": "", + "transcoding_reference_frames_description": "", + "transcoding_required_description": "", + "transcoding_settings": "Nastavenia video transkódovania", + "transcoding_settings_description": "", + "transcoding_target_resolution": "", + "transcoding_target_resolution_description": "", + "transcoding_temporal_aq": "", + "transcoding_temporal_aq_description": "", + "transcoding_threads": "Vlákna", + "transcoding_threads_description": "", + "transcoding_tone_mapping": "", + "transcoding_tone_mapping_description": "", + "transcoding_tone_mapping_npl": "", + "transcoding_tone_mapping_npl_description": "", + "transcoding_transcode_policy": "", + "transcoding_two_pass_encoding": "", + "transcoding_two_pass_encoding_setting_description": "", + "transcoding_video_codec": "", + "transcoding_video_codec_description": "", + "trash_enabled_description": "Povoliť funkcie koša", + "trash_number_of_days": "Počet dní", + "trash_number_of_days_description": "", + "trash_settings": "Nastavenia koša", + "trash_settings_description": "Spravovať nastavenia koša", + "user_delete_delay_settings": "Odstrániť oneskorenie", + "user_delete_delay_settings_description": "", + "user_management": "Správa používateľov", + "user_password_has_been_reset": "Heslo používateľa bolo resetované:", + "user_settings": "Nastavenia používateľa", + "user_settings_description": "Spravovať používateľské nastavenia", + "user_successfully_removed": "Používateľ {email} bol úspešne odstránený.", + "version_check_enabled_description": "Povoliť kontrolu verzie", + "version_check_settings": "Kontrola verzie", + "version_check_settings_description": "Povoliť/zakázať upozornenia na novú verziu", + "video_conversion_job": "Prekódovať videá", + "video_conversion_job_description": "" + }, + "admin_email": "Administrátorský email", + "admin_password": "Administrátorské heslo", + "administration": "Administrácia", + "advanced": "Pokročilé", + "album_added": "Album bol pridaný", + "album_added_notification_setting_description": "Obdržať upozornenie emailom, keď ste pridaní do zdieľaného albumu", + "album_cover_updated": "", + "album_delete_confirmation": "Ste si istý, že chcete odstrániť album {album}?", + "album_info_updated": "Informácie albumu aktualizované", + "album_leave": "Opustiť album?", + "album_leave_confirmation": "Ste si istý, že chcete opustiť album {album}?", + "album_name": "Názov albumu", + "album_options": "Nastavenia albumu", + "album_remove_user": "Odstrániť používateľa?", + "album_remove_user_confirmation": "Ste si istý, že chcete odstrániť používateľa {user}?", + "album_updated": "Album bol aktualizovaný", + "album_updated_setting_description": "Obdržať e-mailové upozornenie, keď v zdieľanom albume pribudnú nové položky", + "album_user_left": "Opustil {album}", + "album_with_link_access": "Umožnite komukoľvek s odkazom pozrieť si fotky a ľudí v tomto albume.", + "albums": "Albumy", + "all": "Všetko", + "all_albums": "Všetky albumy", + "all_people": "Všetci ľudia", + "all_videos": "Všetky videa", + "allow_dark_mode": "Povoliť tmavý režim", + "allow_edits": "Povoliť úpravy", + "anti_clockwise": "Proti smeru hodinových ručičiek", + "api_key": "API Klúč", + "api_key_empty": "Názov vášho API kĺuča by nemal byť prázdny", + "api_keys": "API Kľúče", + "app_settings": "Nastavenia Aplikácie", + "appears_in": "", + "archive": "Archivovať", + "archive_or_unarchive_photo": "", + "archived": "", + "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_offline": "", + "asset_skipped": "Preskočené", + "asset_skipped_in_trash": "V koši", + "asset_uploaded": "Nahrané", + "asset_uploading": "Nahráva sa...", + "assets": "Položky", + "authorized_devices": "Autorizované zariadenia", + "back": "Späť", + "backward": "", + "birthdate_saved": "Dátum narodenia bol úspešne uložený", + "blurred_background": "", + "buy": "Kúpiť Immich", + "camera": "Fotoaparát", + "camera_brand": "Výrobca fotoaparátu", + "camera_model": "Model fotoaparátu", + "cancel": "Zrušiť", + "cancel_search": "Zrušiť vyhľadávanie", + "cannot_merge_people": "", + "cannot_update_the_description": "Popis nie je možné aktualizovať", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "Upraviť dátum", + "change_expiration_time": "Zmeniť čas vypršania", + "change_location": "Upraviť lokáciu", + "change_name": "Upraviť meno", + "change_name_successfully": "", + "change_password": "Zmeniť Heslo", + "change_your_password": "", + "changed_visibility_successfully": "", + "check_all": "Skontrolovať Všetko", + "check_logs": "Skontrolovať logy", + "city": "Mesto", + "clear": "VYMAZAŤ", + "clear_all": "Vymazať všetko", + "clear_all_recent_searches": "Vymazať nedávne vyhľadávania", + "clear_message": "Vymazať správu", + "clear_value": "Vymazať hodnotu", + "clockwise": "V smere hodinových ručičiek", + "close": "Zatvoriť", + "collapse_all": "", + "color_theme": "", + "comment_deleted": "Komentár bol odstránený", + "comment_options": "Možnosti komentára", + "comments_are_disabled": "Komentáre sú vypnuté", + "confirm": "Potvrdiť", + "confirm_admin_password": "Potvrdiť Administrátorské Heslo", + "confirm_delete_shared_link": "Ste si istý, že chcete odstrániť tento zdieľaný odkaz?", + "confirm_password": "Potvrdiť heslo", + "contain": "", + "context": "Kontext", + "continue": "Pokračovať", + "copied_image_to_clipboard": "Obrázok skopírovaný do schránky.", + "copied_to_clipboard": "Skopírované do schránky!", + "copy_error": "Chyba pri kopírovaní", + "copy_file_path": "", + "copy_image": "Skopírovať obrázok", + "copy_link": "Skopírovať odkaz", + "copy_link_to_clipboard": "Skopírovať do schránky", + "copy_password": "Skopírovať heslo", + "copy_to_clipboard": "Skopírovať do schránky", + "country": "Štát", + "cover": "", + "covers": "", + "create": "Vytvoriť", + "create_album": "Vytvoriť album", + "create_library": "Vytvoriť knižnicu", + "create_link": "Vytvoriť odkaz", + "create_link_to_share": "Vytvoriť odkaz na zdieľanie", + "create_new_person": "Vytvoriť novú osobu", + "create_new_user": "Vytvorenie nového používateľa", + "create_tag": "Vytvoriť značku", + "create_user": "Vytvoriť používateľa", + "created": "Vytvorené", + "current_device": "Aktuálne zariadenie", + "custom_locale": "", + "custom_locale_description": "", + "dark": "Tmavý", + "date_after": "Dátum po", + "date_and_time": "Dátum a čas", + "date_before": "Dátum pred", + "date_of_birth_saved": "Dátum narodenia uložený", + "date_range": "Rozsah dátumu", + "day": "Deň", + "default_locale": "", + "default_locale_description": "", + "delete": "Vymazať", + "delete_album": "Odstrániť album", + "delete_api_key_prompt": "Naozaj chcete odstrániť tento API kľúč?", + "delete_key": "Vymazať kľúč", + "delete_library": "Odstrániť knižnicu", + "delete_link": "Odstrániť link", + "delete_shared_link": "Odstrániť zdieľaný odkaz", + "delete_tag": "Odstrániť označenie", + "delete_user": "Odstrániť používateľa", + "deleted_shared_link": "", + "description": "Popis", + "details": "PODROBNOSTI", + "direction": "Smer", + "disabled": "Vypnuté", + "disallow_edits": "", + "discord": "Discord", + "discover": "Preskúmať", + "dismiss_all_errors": "", + "dismiss_error": "", + "display_options": "Zobraziť možnosti", + "display_order": "", + "display_original_photos": "Zobraziť pôvodné fotografie", + "display_original_photos_setting_description": "", + "do_not_show_again": "Túto správu znova nezobrazovať", + "documentation": "Dokumentácia", + "done": "Hotovo", + "download": "Stiahnuť", + "download_settings": "Stiahnuť", + "downloading": "Sťahovanie", + "downloading_asset_filename": "Stahovanie súboru {filename}", + "duplicates": "Duplikáty", + "duration": "Trvanie", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "Upraviť", + "edit_album": "Upraviť album", + "edit_avatar": "Upraviť postavu", + "edit_date": "Upraviť dátum", + "edit_date_and_time": "Upraviť dátum a čas", + "edit_exclusion_pattern": "", + "edit_faces": "Upraviť tváre", + "edit_import_path": "", + "edit_import_paths": "", + "edit_key": "Upraviť kľúč", + "edit_link": "Upraviť odkaz", + "edit_location": "Upraviť polohu", + "edit_name": "Upraviť meno", + "edit_people": "Upraviť ľudí", + "edit_tag": "Upraiť značku", + "edit_title": "Upraviť názov", + "edit_user": "Upraviť používateľa", + "edited": "Upravené", + "editor": "", + "editor_close_without_save_prompt": "Úpravy nebudú uložené", + "editor_crop_tool_h2_aspect_ratios": "Pomer strán", + "editor_crop_tool_h2_rotation": "Rotácia", + "email": "E-mail", + "empty": "", + "empty_album": "", + "empty_trash": "Vyprázdniť kôš", + "enable": "Aktivovať", + "enabled": "Aktivovaný", + "end_date": "", + "error": "Chyba", + "error_loading_image": "Chyba pri načítaní obrázku", + "error_title": "Chyba - niečo sa pokazilo", + "errors": { + "unable_to_add_album_users": "", + "unable_to_add_comment": "", + "unable_to_add_partners": "", + "unable_to_change_album_user_role": "", + "unable_to_change_date": "", + "unable_to_change_location": "", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_create_admin_account": "", + "unable_to_create_library": "", + "unable_to_create_user": "", + "unable_to_delete_album": "", + "unable_to_delete_asset": "", + "unable_to_delete_user": "", + "unable_to_empty_trash": "", + "unable_to_enter_fullscreen": "", + "unable_to_exit_fullscreen": "", + "unable_to_hide_person": "", + "unable_to_load_album": "", + "unable_to_load_asset_activity": "", + "unable_to_load_items": "", + "unable_to_load_liked_status": "", + "unable_to_play_video": "", + "unable_to_refresh_user": "", + "unable_to_remove_album_users": "", + "unable_to_remove_comment": "", + "unable_to_remove_library": "", + "unable_to_remove_partner": "", + "unable_to_remove_reaction": "", + "unable_to_remove_user": "", + "unable_to_repair_items": "", + "unable_to_reset_password": "", + "unable_to_resolve_duplicate": "", + "unable_to_restore_assets": "", + "unable_to_restore_trash": "", + "unable_to_restore_user": "", + "unable_to_save_album": "", + "unable_to_save_name": "", + "unable_to_save_profile": "", + "unable_to_save_settings": "", + "unable_to_scan_libraries": "", + "unable_to_scan_library": "", + "unable_to_set_profile_picture": "", + "unable_to_submit_job": "", + "unable_to_trash_asset": "", + "unable_to_unlink_account": "", + "unable_to_update_library": "", + "unable_to_update_location": "", + "unable_to_update_settings": "", + "unable_to_update_user": "" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "Exif", + "exit_slideshow": "Opustiť Slideshow", + "expand_all": "", + "expire_after": "Expiruje po", + "expired": "Vypršalo", + "explore": "Preskúmať", + "explorer": "Prieskumník", + "export": "Exportovať", + "export_as_json": "Exportovať ako JSON", + "extension": "Rozšírenie", + "external": "Externý", + "external_libraries": "", + "failed_to_get_people": "", + "favorite": "Obľúbené", + "favorite_or_unfavorite_photo": "", + "favorites": "Obľúbené", + "feature": "", + "feature_photo_updated": "", + "featurecollection": "", + "features": "Funkcie", + "file_name": "Meno súboru", + "file_name_or_extension": "", + "filename": "Meno súboru", + "files": "", + "filetype": "Typ súboru", + "filter_people": "Filtrovať ľudí", + "find_them_fast": "Nájdite ich rýchlejšie podľa mena", + "fix_incorrect_match": "", + "force_re-scan_library_files": "", + "forward": "", + "general": "Všeobecné", + "get_help": "", + "getting_started": "", + "go_back": "", + "go_to_search": "", + "go_to_share_page": "", + "group_albums_by": "", + "has_quota": "", + "hide_gallery": "", + "hide_password": "", + "hide_person": "", + "host": "", + "hour": "", + "image": "", + "img": "", + "immich_logo": "", + "import_path": "", + "in_archive": "", + "include_archived": "Zahrnúť archivované", + "include_shared_albums": "", + "include_shared_partner_assets": "", + "individual_share": "", + "info": "", + "interval": { + "day_at_onepm": "", + "hours": "", + "night_at_midnight": "", + "night_at_twoam": "" + }, + "invite_people": "", + "invite_to_album": "Pozvať do albumu", + "job_settings_description": "", + "jobs": "", + "keep": "", + "keyboard_shortcuts": "", + "language": "", + "language_setting_description": "", + "last_seen": "", + "leave": "", + "let_others_respond": "Nechajte ostatných reagovať", + "level": "", + "library": "Knižnica", + "library_options": "", + "light": "", + "link_options": "", + "link_to_oauth": "", + "linked_oauth_account": "", + "list": "", + "loading": "", + "loading_search_results_failed": "", + "log_out": "Odhlásiť sa", + "log_out_all_devices": "", + "login_has_been_disabled": "Prihlásenie bolo vypnuté.", + "look": "", + "loop_videos": "", + "loop_videos_description": "", + "make": "", + "manage_shared_links": "Spravovať zdieľané odkazy", + "manage_sharing_with_partners": "", + "manage_the_app_settings": "", + "manage_your_account": "", + "manage_your_api_keys": "", + "manage_your_devices": "", + "manage_your_oauth_connection": "", + "map": "Mapa", + "map_marker_with_image": "", + "map_settings": "Nastavenia máp", + "media_type": "", + "memories": "", + "memories_setting_description": "", + "menu": "", + "merge": "", + "merge_people": "", + "merge_people_successfully": "", + "minimize": "", + "minute": "", + "missing": "", + "model": "", + "month": "Mesiac", + "more": "", + "moved_to_trash": "", + "my_albums": "", + "name": "Meno", + "name_or_nickname": "", + "never": "nikdy", + "new_api_key": "", + "new_password": "Nové heslo", + "new_person": "", + "new_user_created": "", + "new_version_available": "JE DOSTUPNÁ NOVÁ VERZIA", + "newest_first": "", + "next": "Ďalej", + "next_memory": "", + "no": "", + "no_albums_message": "", + "no_archived_assets_message": "Archivovať fotografie a videá, aby sa skryli zo zobrazenia Fotografie", + "no_assets_message": "", + "no_exif_info_available": "", + "no_explore_results_message": "", + "no_favorites_message": "", + "no_libraries_message": "", + "no_name": "", + "no_places": "", + "no_results": "", + "no_shared_albums_message": "", + "not_in_any_album": "", + "notes": "", + "notification_toggle_setting_description": "Povoliť e-mailové upozornenia", + "notifications": "Oznámenia", + "notifications_setting_description": "Spravovať upozornenia", + "oauth": "OAuth", + "offline": "", + "ok": "", + "oldest_first": "", + "onboarding_welcome_user": "Vitaj, {user}", + "online": "", + "only_favorites": "", + "only_refreshes_modified_files": "", + "open_the_search_filters": "", + "options": "Nastavenia", + "or": "alebo", + "organize_your_library": "Usporiadajte svoju knižnicu", + "other": "", + "other_devices": "Ďalšie zariadenia", + "other_variables": "", + "owned": "Vlastnené", + "owner": "Vlastník", + "partner_sharing": "", + "partners": "", + "password": "Heslo", + "password_does_not_match": "", + "password_required": "", + "password_reset_success": "", + "past_durations": { + "days": "", + "hours": "", + "years": "" + }, + "path": "", + "pattern": "", + "pause": "", + "pause_memories": "", + "paused": "", + "pending": "", + "people": "Ľudia", + "people_sidebar_description": "", + "perform_library_tasks": "", + "permanent_deletion_warning": "", + "permanent_deletion_warning_setting_description": "", + "permanently_delete": "", + "permanently_deleted_asset": "", + "photos": "Fotografie", + "photos_and_videos": "Fotografie & Videa", + "photos_from_previous_years": "", + "pick_a_location": "", + "place": "Miesto", + "places": "Miesta", + "play": "Prehrať", + "play_memories": "", + "play_motion_photo": "", + "play_or_pause_video": "", + "point": "", + "port": "", + "preset": "", + "preview": "", + "previous": "", + "previous_memory": "", + "previous_or_next_photo": "", + "primary": "", + "profile_picture_set": "", + "public_album": "Verejný album", + "public_share": "", + "purchase_activated_time": "Aktivované {date, date}", + "purchase_button_activate": "Aktivovať", + "purchase_button_never_show_again": "Už viac nezobrazovať", + "purchase_panel_title": "Podporiť projekt", + "range": "", + "raw": "", + "reaction_options": "", + "read_changelog": "", + "recent": "Nedávne", + "recent_searches": "", + "refresh": "Obnoviť", + "refresh_metadata": "Obnoviť metadáta", + "refresh_thumbnails": "Obnoviť miniatúry", + "refreshed": "Aktualizované", + "refreshes_every_file": "", + "remove": "Odstrániť", + "remove_deleted_assets": "", + "remove_from_album": "Odstrániť z albumu", + "remove_from_favorites": "", + "remove_from_shared_link": "", + "remove_user": "Odstrániť používateľa", + "repair": "Opraviť", + "repair_no_results_message": "", + "replace_with_upload": "", + "require_password": "Vyžadovať heslo", + "reset": "Resetovať", + "reset_password": "Obnoviť heslo", + "reset_people_visibility": "", + "reset_settings_to_default": "", + "restore": "Obnoviť", + "restore_user": "Obnoviť používateľa", + "resume": "Pokračovať", + "retry_upload": "", + "review_duplicates": "Skontrolovať duplikáty", + "role": "", + "save": "Uložiť", + "saved_profile": "", + "saved_settings": "", + "say_something": "Napíšte niečo", + "scan_all_libraries": "", + "scan_all_library_files": "", + "scan_new_library_files": "", + "scan_settings": "Nastavenia skenovania", + "search": "Vyhľadávanie", + "search_albums": "Hľadať albumy", + "search_by_context": "", + "search_by_filename_example": "napr. IMG_1234.JPG alebo PNG", + "search_camera_make": "", + "search_camera_model": "", + "search_city": "", + "search_country": "", + "search_for_existing_person": "", + "search_people": "", + "search_places": "", + "search_settings": "Hladať v nastaveniach", + "search_state": "", + "search_timezone": "Vyhľadať časovú zónu...", + "search_type": "", + "search_your_photos": "Prehľadajte svoje obrázky", + "searching_locales": "", + "second": "", + "select_album_cover": "", + "select_all": "", + "select_avatar_color": "", + "select_face": "", + "select_featured_photo": "", + "select_library_owner": "Vybraťi vlastníka knižnice", + "select_new_face": "", + "select_photos": "Vybrať fotografie", + "selected": "Vybraté", + "send_message": "Odoslať správu", + "send_welcome_email": "Odoslať uvítací e-mail", + "server": "", + "server_stats": "Štatistiky servera", + "server_version": "Verzia servera", + "set": "Nastaviť", + "set_as_album_cover": "", + "set_as_profile_picture": "Nastaviť ako profilový obrázok", + "set_date_of_birth": "Nastaviť dátum narodenia", + "set_profile_picture": "Nastaviť profilový obrázok", + "set_slideshow_to_fullscreen": "", + "settings": "Nastavenia", + "settings_saved": "Nastavenia boli uložené", + "share": "Zdieľať", + "shared": "Zdieľané", + "shared_by": "", + "shared_by_you": "", + "shared_from_partner": "Fotografie od {partner}", + "shared_links": "Zdieľané odkazy", + "shared_with_partner": "Zďielané s {partner}", + "sharing": "Zdieľanie", + "sharing_sidebar_description": "", + "show_album_options": "Zobraziť možnosti albumu", + "show_albums": "Zobraziť albumy", + "show_file_location": "", + "show_gallery": "Zobraziť galériu", + "show_hidden_people": "", + "show_in_timeline": "Zobraziť na časovej osi", + "show_in_timeline_setting_description": "", + "show_keyboard_shortcuts": "Zobraziť klávesové skratky", + "show_metadata": "Zobraziť metadáta", + "show_or_hide_info": "", + "show_password": "Zobraziť heslo", + "show_person_options": "", + "show_progress_bar": "", + "show_search_options": "Zobraziť možnosti vyhľadávania", + "shuffle": "", + "sign_out": "Odhlásiť sa", + "sign_up": "", + "size": "Veľkosť", + "skip_to_content": "", + "slideshow": "", + "slideshow_settings": "", + "sort_albums_by": "Zoradiť albumy podľa...", + "sort_created": "Dátum vytvorenia", + "sort_items": "Počet položiek", + "sort_modified": "Dátum úpravy", + "sort_oldest": "Najstaršia fotografia", + "sort_recent": "Najnovšia fotografia", + "sort_title": "Názov", + "source": "Zdroj", + "stack": "Zoskupenie", + "stack_selected_photos": "", + "stacktrace": "", + "start_date": "", + "state": "", + "status": "", + "stop_motion_photo": "", + "stop_photo_sharing": "Zastaviť zdieľanie vašich fotiek?", + "storage": "Ukladací priestor", + "storage_label": "", + "submit": "Odoslať", + "suggestions": "Návrhy", + "sunrise_on_the_beach": "", + "swap_merge_direction": "", + "sync": "", + "tags": "Značky", + "template": "", + "theme": "Téma", + "theme_selection": "", + "theme_selection_description": "", + "time_based_memories": "", + "timezone": "Časové pásmo", + "to_archive": "Archivovať", + "to_change_password": "Zmeniť heslo", + "to_trash": "Kôš", + "toggle_settings": "", + "toggle_theme": "", + "toggle_visibility": "", + "total_usage": "", + "trash": "Kôš", + "trash_all": "", + "trash_no_results_message": "Vymazané fotografie a videá sa zobrazia tu.", + "type": "", + "unarchive": "Odarchivovať", + "unarchived": "", + "unfavorite": "Odznačiť ako obľúbené", + "unhide_person": "", + "unknown": "", + "unknown_album": "", + "unknown_year": "Neznámy rok", + "unlink_oauth": "", + "unlinked_oauth_account": "", + "unnamed_album_delete_confirmation": "Ste si istý, že chcete zmazať tento album?", + "unsaved_change": "Neuložená zmena", + "unselect_all": "", + "unstack": "Odskupiť", + "up_next": "", + "updated_password": "", + "upload": "Nahrať", + "upload_concurrency": "", + "upload_status_duplicates": "Duplikáty", + "upload_status_errors": "Chyby", + "upload_status_uploaded": "Nahrané", + "upload_success": "Nahrávanie úspešné, pridané súbory sa zobrazia po obnovení stránky.", + "url": "Odkaz URL", + "usage": "Použitie", + "user": "Používateľ", + "user_id": "Používateľské ID", + "user_role_set": "Nastav {user} ako {role}", + "user_usage_detail": "", + "username": "Používateľské meno", + "users": "Používatelia", + "utilities": "Nástroje", + "validate": "Validovať", + "variables": "Premenné", + "version": "Verzia", + "version_announcement_closing": "Tvoj kamarát, Alex", + "version_history": "História verzií", + "video": "Video", + "video_hover_setting_description": "", + "videos": "Videá", + "view": "Zobraziť", + "view_album": "Zobraziť Album", + "view_all": "Zobraziť všetky", + "view_all_users": "Zobraziť všetkých používateľov", + "view_in_timeline": "Zobraziť v časovej osi", + "view_links": "Zobraziť odkazy", + "view_next_asset": "Zobraziť nasledujúci súbor", + "view_previous_asset": "Zobraziť predchádzajúci súbor", + "viewer": "", + "waiting": "", + "warning": "Varovanie", + "week": "Týždeň", + "welcome": "Vitajte", + "welcome_to_immich": "Vytajte v immich", + "year": "Rok", + "yes": "Áno", + "you_dont_have_any_shared_links": "Nemáte žiadne zdielané linky", + "zoom_image": "Priblížiť obrázok" +} diff --git a/web/src/lib/i18n/sl.json b/i18n/sl.json similarity index 98% rename from web/src/lib/i18n/sl.json rename to i18n/sl.json index d4e50ac8f4..dd14e5ef94 100644 --- a/web/src/lib/i18n/sl.json +++ b/i18n/sl.json @@ -7,6 +7,7 @@ "actions": "Dejanja", "active": "Aktivno", "activity": "Aktivnost", + "activity_changed": "Aktivnost {enabled, select, true {omogočena} other {onemogočena}}", "add": "Dodaj", "add_a_description": "Dodaj opis", "add_a_location": "Dodaj lokacijo", @@ -22,10 +23,14 @@ "add_to": "Dodaj k...", "add_to_album": "Dodaj v album", "add_to_shared_album": "Dodaj k deljenemu albumu", + "added_to_archive": "Dodano v arhiv", + "added_to_favorites": "Dodano med priljubljene", + "added_to_favorites_count": "{count, number} dodanih med priljubljene", "admin": { "add_exclusion_pattern_description": "Dodajte vzorec izključitev. Globiranje z uporabo *, ** in ? je podprto. Če želite prezreti vse datoteke v katerem koli imeniku z imenom \"Raw\", uporabite \"**/Raw/**\". Če želite prezreti vse datoteke, ki se končajo na \".tif\", uporabite \"**/*.tif\". Če želite prezreti absolutno pot, uporabite \"/pot/za/ignoriranje/**\".", "authentication_settings": "Nastavitve preverjanja pristnosti", "authentication_settings_description": "Upravljanje gesel, OAuth in drugih nastavitev preverjanja pristnosti", + "authentication_settings_disable_all": "Ali zares želite onemogočiti vse prijavne metode? Prijava bo popolnoma onemogočena.", "authentication_settings_reenable": "Ponovno omogoči z uporabo Server Command.", "background_task_job": "Opravila v ozadju", "check_all": "Označi vse", @@ -666,10 +671,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "Odstrani iz albuma", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "repair": "", "repair_no_results_message": "", "replace_with_upload": "", @@ -820,8 +825,9 @@ "viewer": "", "waiting": "", "week": "", + "welcome": "Dobrodošli", "welcome_to_immich": "", - "year": "", + "year": "Leto", "yes": "Da", - "zoom_image": "" + "zoom_image": "Povečava slike" } diff --git a/web/src/lib/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json similarity index 88% rename from web/src/lib/i18n/sr_Cyrl.json rename to i18n/sr_Cyrl.json index 761668d386..a14c5bbee4 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -25,9 +25,10 @@ "add_to_shared_album": "Додај у дељен албум", "added_to_archive": "Додато у архиву", "added_to_favorites": "Додато у фаворите", - "added_to_favorites_count": "Додато {count} у фаворите", + "added_to_favorites_count": "Додато {count, number} у фаворите", "admin": { "add_exclusion_pattern_description": "Додајте обрасце искључења. Кориштење *, ** и ? је подржано. Да бисте игнорисали све датотеке у било ком директоријуму под називом „Рав“, користите „**/Рав/**“. Да бисте игнорисали све датотеке које се завршавају на „.тиф“, користите „**/*.тиф“. Да бисте игнорисали апсолутну путању, користите „/path/to/ignore/**“.", + "asset_offline_description": "Ово екстерно библиотечко средство се више не налази на диску и премештено је у смеће. Ако је датотека премештена унутар библиотеке, проверите своју временску линију за ново одговарајуће средство. Да бисте вратили ово средство, уверите се да Иммицх може да приступи доле наведеној путањи датотеке и скенирајте библиотеку.", "authentication_settings": "Подешавања за аутентификацију", "authentication_settings_description": "Управљајте лозинком, OAuth-om и другим подешавањима аутентификације", "authentication_settings_disable_all": "Да ли сте сигурни да желите да oneмогућите све методе пријављивања? Пријава ће бити потпуно oneмогућена.", @@ -41,6 +42,7 @@ "confirm_email_below": "Да бисте потврдили, унесите \"{email}\" испод", "confirm_reprocess_all_faces": "Да ли сте сигурни да желите да поново обрадите сва лица? Ово ће такође обрисати именоване особе.", "confirm_user_password_reset": "Да ли сте сигурни да желите да ресетујете лозинку корисника {user}?", + "create_job": "Креирајте посао", "crontab_guru": "Guru servisnih zadataka", "disable_login": "oneмогући пријаву", "disabled": "", @@ -49,27 +51,37 @@ "external_library_created_at": "Екстерна библиотека (направљена {date})", "external_library_management": "Управљање екстерним библиотекама", "face_detection": "Детекција лица", - "face_detection_description": "Откривање лица у датотекама помоћу машинског учења. За видео снимке се узима у обзир само сличица. „Све“ (поновно) обрађује све датотеке. „Недостају“ средства у низу која још нису обрађена. Откривена лица ће бити стављена у ред за препознавање лица након што се препознавање лица заврши, групишући их у постојеће или нове људе.", - "facial_recognition_job_description": "Група је детектовала лица и додала их постојецим људима. Овај корак се покреће након што је препознавање лица завршено. „Све“ (поновно) групише сва лица. „Недостају“ лица у редовима којима није додељена особа.", + "face_detection_description": "Откријте лица у датотекама помоћу машинског учења. За видео снимке се узима у обзир само сличица. „Освежи“ (поновно) обрађује све датотеке. „Ресетовање“ додатно брише све тренутне податке о лицу. „Недостају“ датотеке у реду које још нису обрађене. Откривена лица ће бити стављена у ред за препознавање лица након што се препознавање лица заврши, групишући их у постојеће или нове особе.", + "facial_recognition_job_description": "Група је детектовала лица и додала их постојећим људима. Овај корак се покреће након што је препознавање лица завршено. „Ресет“ (поновно) групише сва лица. „Недостају“ лица у редовима којима није додељена особа.", "failed_job_command": "Команда {command} није успела за посао {job}", "force_delete_user_warning": "УПОЗОРЕЊЕ: Ovo će odmah ukloniti korisnika i sve datoteke. Ovo se ne može opozvati i datoteke se ne mogu oporaviti.", "forcing_refresh_library_files": "Принудно освежавање свих датотека библиотеке", + "image_format": "Формат", "image_format_description": "WebP производи мање датотеке од ЈПЕГ, али се спорије кодира.", "image_prefer_embedded_preview": "Преферирајте уграђени преглед", "image_prefer_embedded_preview_setting_description": "Користите уграђене прегледе у RAW фотографије као улаз за обраду слике када су доступне. Ово може да произведе прецизније боје за неке слике, али квалитет прегледа зависи од камере и слика може имати више неправилности компресије.", "image_prefer_wide_gamut": "Преферирајте широк спектар", "image_prefer_wide_gamut_setting_description": "Користите Display П3 за сличице. Ово боље чува живописност слика са широким просторима боја, али слике могу изгледати другачије на старим уређајима са старом верзијом претраживача. сРГБ слике се чувају као сРГБ да би се избегле промене боја.", + "image_preview_description": "Слика средње величине са уклоњеним метаподацима, која се користи приликом прегледа једног елемента и за машинско учење", "image_preview_format": "Преглед формата", + "image_preview_quality_description": "Квалитет прегледа од 1-100. Више је боље, али производи веће датотеке и може смањити одзив апликације. Постављање ниске вредности може утицати на квалитет машинског учења.", "image_preview_resolution": "Преглед резолуције", "image_preview_resolution_description": "Користи се за гледање једне фотографије и за машинско учење. Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање брзину апликације.", + "image_preview_title": "Подешавања прегледа", "image_quality": "Квалитет", "image_quality_description": "Квалитет слике од 1-100. Више је боље за квалитет, али производи веће датотеке, ова опција утиче на преглед и сличице.", + "image_resolution": "Резолуција", + "image_resolution_description": "Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање одзив апликације.", "image_settings": "Подешавања слике", "image_settings_description": "Управљајте квалитетом и резолуцијом генерисаних слика", + "image_thumbnail_description": "Мала сличица са огољеним метаподацима, која се користи приликом прегледа група фотографија као што је главна временска линија", "image_thumbnail_format": "Формат сличице", + "image_thumbnail_quality_description": "Квалитет сличица од 1-100. Више је боље, али производи веће датотеке и може смањити одзив апликације.", "image_thumbnail_resolution": "Резолуција сличице", "image_thumbnail_resolution_description": "Користи се приликом прегледа група фотографија (главна временска линија, приказ албума, итд.). Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање брзину апликације.", + "image_thumbnail_title": "Подешавања сличица", "job_concurrency": "{job} паралелност", + "job_created": "Посао креиран", "job_not_concurrency_safe": "Овај посао није безбедан да буде паралелно активан.", "job_settings": "Подешавања посла", "job_settings_description": "Управљајте паралелношћу послова", @@ -129,6 +141,7 @@ "map_enable_description": "Омогућите карактеристике мапе", "map_gps_settings": "Мап & ГПС подешавања", "map_gps_settings_description": "Управљајте поставкама мапе и ГПС-а (обрнуто геокодирање)", + "map_implications": "Функција мапе се ослања на екстерну услугу плочица (tiles.immich.cloud)", "map_light_style": "Светли стил", "map_manage_reverse_geocoding_settings": "Управљајте подешавањима Обрнуто геокодирање", "map_reverse_geocoding": "Обрнуто геокодирање", @@ -138,7 +151,11 @@ "map_settings_description": "Управљајте подешавањима мапе", "map_style_description": "УРЛ до style.json мапе тема изгледа", "metadata_extraction_job": "Извод метаподатака", - "metadata_extraction_job_description": "Извуците информације о метаподацима из сваке датотеке, као што су ГПС и резолуција", + "metadata_extraction_job_description": "Извуците информације о метаподацима из сваке датотеке, као што су GPS, лица и резолуција", + "metadata_faces_import_setting": "Омогући (enable) увоз лица", + "metadata_faces_import_setting_description": "Увезите лица из EXIF података слика и датотека са бочне траке", + "metadata_settings": "Подешавања метаподатака", + "metadata_settings_description": "Управљајте подешавањима метаподатака", "migration_job": "Миграције", "migration_job_description": "Пренесите сличице датотека и лица у најновију структуру директоријума", "no_paths_added": "Нема додатих путања", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "НАПОМЕНА: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Напомена: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Са адресе", - "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", + "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", "notification_email_host_description": "Хост сервера е-поште (нпр. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Занемарите грешке сертификата", "notification_email_ignore_certificate_errors_description": "Игноришите грешке у валидацији ТЛС сертификата (не препоручује се)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "УРЛ издавача", "oauth_mobile_redirect_uri": "УРИ за преусмеравање мобилних уређаја", "oauth_mobile_redirect_uri_override": "Замена УРИ-ја мобилног преусмеравања", - "oauth_mobile_redirect_uri_override_description": "Омогући када је 'app.immich:/' nevažeći URI za preusmeravanje.", + "oauth_mobile_redirect_uri_override_description": "Омогући када ОАuth добављач (provider) не дозвољава мобилни URI, као што је '{callback}'", "oauth_profile_signing_algorithm": "Алгоритам за потписивање профила", "oauth_profile_signing_algorithm_description": "Алгоритам који се користи за потписивање корисничког профила.", "oauth_scope": "Обим", @@ -193,19 +210,22 @@ "password_settings": "Лозинка за пријаву", "password_settings_description": "Управљајте подешавањима за пријаву лозинком", "paths_validated_successfully": "Све путање су успешно потврђене", + "person_cleanup_job": "Чишћење особа", "quota_size_gib": "Величина квоте (ГиБ)", "refreshing_all_libraries": "Освежавање свих библиотека", "registration": "Регистрација администратора", "registration_description": "Пошто сте први корисник на систему, бићете додељени као Админ и одговорни сте за административне задатке, а додатне кориснике ћете креирати ви.", - "removing_offline_files": "Уклањање ванмрежних датотека", + "removing_deleted_files": "Уклањање ванмрежних датотека", "repair_all": "Поправи све", "repair_matched_items": "Поклапа се са {count, plural, one {1 ставком} few {# ставке} other {# ставки}}", "repaired_items": "{count, plural, one {Поправљена 1 ставка} few {Поправљене # ставке} other {Поправљене # ставки}}", "require_password_change_on_login": "Захтевати од корисника да промени лозинку при првом пријављивању", "reset_settings_to_default": "Ресетујте подешавања на подразумеване вредности", "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", + "scanning_library": "Скенирање библиотеке", "scanning_library_for_changed_files": "Скенирање библиотеке за промењене датотеке", "scanning_library_for_new_files": "Скенирање библиотеке за нове датотеке", + "search_jobs": "Тражи послове...", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Управљајте структуром директоријума и именом датотеке средства за отпремање", "storage_template_user_label": "{label} је ознака за складиштење корисника", "system_settings": "Подешавања система", + "tag_cleanup_job": "Чишћење ознака (tags)", "theme_custom_css_settings": "Прилагођени CSS", "theme_custom_css_settings_description": "Каскадни листови стилова (CSS) омогућавају прилагођавање дизајна Immich-a.", "theme_settings": "Подешавање тема", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Хардверско убрзање", "transcoding_hardware_acceleration_description": "Екпериментално; много брже, али ће имати нижи квалитет при истој брзини преноса", "transcoding_hardware_decoding": "Хардверско декодирање", - "transcoding_hardware_decoding_setting_description": "Односи се само на НВЕНЦ, QSV и RKMPP. Омогућава убрзање од краја до краја уместо да само убрзава кодирање. Можда неће радити на свим видео снимцима.", + "transcoding_hardware_decoding_setting_description": "Омогућава убрзање од краја до краја уместо да само убрзава кодирање. Можда неће радити на свим видео снимцима.", "transcoding_hevc_codec": "ХЕВЦ кодек", "transcoding_max_b_frames": "Максимални Б-кадри", "transcoding_max_b_frames_description": "Више вредности побољшавају ефикасност компресије, али успоравају кодирање. Можда није компатибилно са хардверским убрзањем на старијим уређајима. 0 oneмогућава Б-кадре, док -1 аутоматски поставља ову вредност.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Жељени хардверски уређај", "transcoding_preferred_hardware_device_description": "Односи се само на ВААПИ и QSV. Поставља дри ноде који се користи за хардверско транскодирање.", "transcoding_preset_preset": "Унапред подешена подешавања (-пресет)", - "transcoding_preset_preset_description": "Брзина компресије. Спорије унапред подешене вредности производе мање датотеке и повећавају квалитет када циљате одређену брзину преноса. ВП9 игнорише брзине изнад `брже`.", + "transcoding_preset_preset_description": "Брзина компресије. Спорије унапред подешене вредности производе мање датотеке и повећавају квалитет када циљате одређену брзину преноса. ВП9 игнорише брзине изнад 'брже'.", "transcoding_reference_frames": "Референтни оквири (фрамес)", "transcoding_reference_frames_description": "Број оквира (фрамес) за референцу приликом компресије датог оквира. Више вредности побољшавају ефикасност компресије, али успоравају кодирање. 0 аутоматски поставља ову вредност.", "transcoding_required_description": "Само видео снимци који нису у прихваћеном формату", @@ -307,6 +328,7 @@ "trash_settings_description": "Управљајте подешавањима смећа", "untracked_files": "Непраћене датотеке", "untracked_files_description": "Апликација не прати ове датотеке. one могу настати због неуспешних премештења, због прекинутих отпремања или као преостатак због грешке", + "user_cleanup_job": "Чишћење корисника", "user_delete_delay": "Налог и датотеке {user} биће заказани за трајно брисање за {delay, plural, one {# дан} other {# дана}}.", "user_delete_delay_settings": "Избриши уз кашњење", "user_delete_delay_settings_description": "Број дана након уклањања за трајно брисање корисничког налога и датотека. Посао брисања корисника се покреће у поноћ да би се проверили корисници који су спремни за брисање. Промене ове поставке ће бити процењене при следећем извршењу.", @@ -320,7 +342,8 @@ "user_settings": "Подешавања корисника", "user_settings_description": "Управљајте корисничким подешавањима", "user_successfully_removed": "Корисник {email} је успешно уклоњен.", - "version_check_enabled_description": "Омогућите периодичне захтеве GitHub-u за проверу нових издања", + "version_check_enabled_description": "Омогућите проверу нових издања", + "version_check_implications": "Функција провере верзије се ослања на периодичну комуникацију са github.com", "version_check_settings": "Провера верзије", "version_check_settings_description": "Омогућите/oneмогућите обавештење о новој верзији", "video_conversion_job": "Транскодирање видео записа", @@ -336,7 +359,8 @@ "album_added": "Албум додан", "album_added_notification_setting_description": "Прими обавештење е-поштом кад будеш додан у дељен албум", "album_cover_updated": "Омот албума ажуриран", - "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?\nАко се овај албум дели, други корисници више неће моћи да му приступе.", + "album_delete_confirmation": "Да ли стварно желите да избришете албум {album}?", + "album_delete_confirmation_description": "Ако се овај албум дели, други корисници више неће моћи да му приступе.", "album_info_updated": "Информација албума ажурирана", "album_leave": "Напустити албум?", "album_leave_confirmation": "Да ли стварно желите да напустите {album}?", @@ -360,6 +384,7 @@ "allow_edits": "Дозволи уређење", "allow_public_user_to_download": "Дозволите јавном кориснику да преузме (download-uje)", "allow_public_user_to_upload": "Дозволи јавном кориснику да отпреми (уплоад-ује)", + "anti_clockwise": "У смеру супротном од казаљке на сату", "api_key": "АПИ кључ (key)", "api_key_description": "Ова вредност ће бити приказана само једном. Обавезно копирајте пре него што затворите прозор.", "api_key_empty": "Име вашег АПИ кључа не би требало да буде празно", @@ -380,9 +405,10 @@ "asset_filename_is_offline": "Датотека {filename} је ван мреже (offline)", "asset_has_unassigned_faces": "Датотека има недодељена лица", "asset_hashing": "Хеширање...", - "asset_offline": "Датотека одсутна", - "asset_offline_description": "Ова датотека је ван мреже. Immich не може да приступи локацији своје датотеке. Уверите се да је датотека доступна, а затим поново скенирајте библиотеку.", + "asset_offline": "Датотека одсутна (offline)", + "asset_offline_description": "Ова вањска датотека се више не налази на диску. Молимо контактирајте свог Имич администратора за помоћ.", "asset_skipped": "Прескочено", + "asset_skipped_in_trash": "У отпад", "asset_uploaded": "Отпремљено (Уплоадед)", "asset_uploading": "Отпремање...", "assets": "Записи", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "Премештено {count, plural, one {# датотека} few {# датотеке} other {# датотека}} у отпад", "assets_permanently_deleted_count": "Трајно избрисано {count, plural, one {# датотека} few {# датотеке} other {# датотека}}", "assets_removed_count": "Уклоњено {count, plural, one {# датотека} few {# датотеке} other {# датотека}}", - "assets_restore_confirmation": "Да ли сте сигурни да желите да вратите све своје датотеке које су у отпаду? Не можете поништити ову радњу!", + "assets_restore_confirmation": "Да ли сте сигурни да желите да вратите све своје датотеке које су у отпаду? Не можете поништити ову радњу! Имајте на уму да се ванмрежна средства не могу вратити на овај начин.", "assets_restored_count": "Враћено {count, plural, one {# датотека} few {# датотеке} other {# датотека}}", "assets_trashed_count": "Бачено у отпад {count, plural, one {# датотека} few{# датотеке} other {# датотека}}", "assets_were_part_of_album_count": "{count, plural, one {Датотека је} other {Датотеке су}} већ део албума", @@ -405,12 +431,13 @@ "birthdate_saved": "Датум рођења успешно сачуван", "birthdate_set_description": "Датум рођења се користи да би се израчунале године ове особе у добу одређене фотографије.", "blurred_background": "Замућена позадина", - "build": "Сагради (Буилд)", + "bugs_and_feature_requests": "Грешке и захтеви за функције", + "build": "Под-верзија (Build)", "build_image": "Сагради (Буилд) имаге", "bulk_delete_duplicates_confirmation": "Да ли сте сигурни да желите групно да избришете {count, plural, one {# дуплиран елеменат} few {# дуплирана елемента} other {# дуплираних елемената}}? Ово ће задржати највеће средство сваке групе и трајно избрисати све друге дупликате. Не можете поништити ову радњу!", "bulk_keep_duplicates_confirmation": "Да ли сте сигурни да желите да задржите {count, plural, one {1 дуплирану датотеку} few {# дуплиране датотеке} other {# дуплираних датотека}}? Ово ће решити све дуплиране групе без брисања било чега.", "bulk_trash_duplicates_confirmation": "Да ли сте сигурни да желите групно да одбаците {count, plural, one {1 дуплирану датотеку} few {# дуплиране датотеке} other {# дуплираних датотека}}? Ово ће задржати највећу датотеку сваке групе и одбацити све остале дупликате.", - "buy": "Купите лиценцу", + "buy": "Купите лиценцу Имич-а", "camera": "Камера", "camera_brand": "Бренд камере", "camera_model": "Модел камере", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Обришите све недавне претраге", "clear_message": "Обриши поруку", "clear_value": "Јасна вредност", + "clockwise": "У смеру казаљке", "close": "Затвори", "collapse": "Скупи", "collapse_all": "Скупи све", + "color": "Боја", "color_theme": "Режим боја", "comment_deleted": "Коментар обрисан", "comment_options": "Опције коментара", @@ -477,10 +506,12 @@ "create_new_person": "Направи нову особу", "create_new_person_hint": "Доделите изабране датотеке новој особи", "create_new_user": "Направи новог корисника", + "create_tag": "Креирајте ознаку (tag)", + "create_tag_description": "Направите нову ознаку (tag). За угнежђене ознаке, унесите пуну путању ознаке укључујући косе црте.", "create_user": "Направи корисника", "created": "Направљен", "current_device": "Тренутни уређај", - "custom_locale": "Прилагођена локација (лоцале)", + "custom_locale": "Прилагођена локација (locale)", "custom_locale_description": "Форматирајте датуме и бројеве на основу језика и региона", "dark": "Тамно", "date_after": "Датум после", @@ -490,7 +521,7 @@ "date_range": "Распон датума", "day": "Дан", "deduplicate_all": "Де-дуплицирај све", - "default_locale": "Подразумевана локација (лоцале)", + "default_locale": "Подразумевана локација (locale)", "default_locale_description": "Форматирајте датуме и бројеве на основу локализације вашег претраживача", "delete": "Обриши", "delete_album": "Обриши албум", @@ -500,13 +531,17 @@ "delete_library": "Обриши библиотеку", "delete_link": "Обриши везу", "delete_shared_link": "Обриши дељену везу", + "delete_tag": "Обриши ознаку (tag)", + "delete_tag_confirmation_prompt": "Да ли стварно желите да избришете ознаку (tag) {tagName}?", "delete_user": "Обриши корисника", "deleted_shared_link": "Обришена дељена веза", + "deletes_missing_assets": "Брише датотеке које недостају са диска", "description": "Опис", "details": "Детаљи", "direction": "Смер", "disabled": "oneмогућено", "disallow_edits": "Забрани измене", + "discord": "Дискорд", "discover": "Откријте", "dismiss_all_errors": "Одбаците све грешке", "dismiss_error": "Одбаци грешку", @@ -515,8 +550,11 @@ "display_original_photos": "Прикажите оригиналне фотографије", "display_original_photos_setting_description": "Радије приказујете оригиналну фотографију када глеdate материјал него сличице када је оригинално дело компатибилно са webom. Ово може довести до споријег приказа фотографија.", "do_not_show_again": "Не прикажи поново ову поруку", + "documentation": "Документација", "done": "Урађено", "download": "Преузми", + "download_include_embedded_motion_videos": "Уграђени видео снимци", + "download_include_embedded_motion_videos_description": "Укључите видео записе уграђене у фотографије у покрету као засебну датотеку", "download_settings": "Преузимање", "download_settings_description": "Управљајте подешавањима везаним за преузимање датотека", "downloading": "Преузимање у току", @@ -546,10 +584,15 @@ "edit_location": "Уреди локацију", "edit_name": "Уреди име", "edit_people": "Уреди особе", + "edit_tag": "Уреди ознаку (tag)", "edit_title": "Уреди титулу", "edit_user": "Уреди корисника", "edited": "Уређено", "editor": "Urednik", + "editor_close_without_save_prompt": "Промене неће бити сачуване", + "editor_close_without_save_title": "Затворити уређивач?", + "editor_crop_tool_h2_aspect_ratios": "Пропорције (aspect ratios)", + "editor_crop_tool_h2_rotation": "Ротација", "email": "Е-пошта", "empty": "", "empty_album": "Isprazni album", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Није могуће добити број коментара", "unable_to_get_shared_link": "Преузимање дељене везе није успело", "unable_to_hide_person": "Није могуће сакрити особу", + "unable_to_link_motion_video": "Није могуће повезати (link) видео снимак", "unable_to_link_oauth_account": "Није могуће повезати OAuth налог", "unable_to_load_album": "Није могуће учитати албум", "unable_to_load_asset_activity": "Није могуће учитати активност средстава", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Није могуће уклонити АПИ кључ (key)", "unable_to_remove_assets_from_shared_link": "Није могуће уклонити елементе са дељеног linkа", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Није могуће уклонити ванмрежне датотеке", "unable_to_remove_library": "Није могуће уклонити библиотеку", - "unable_to_remove_offline_files": "Није могуће уклонити ванмрежне датотеке", "unable_to_remove_partner": "Није могуће уклонити партнера", "unable_to_remove_reaction": "Није могуће уклонити реакцију", "unable_to_remove_user": "", @@ -679,6 +723,7 @@ "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": "Није могуће ажурирати библиотеку", @@ -699,6 +744,7 @@ "expired": "Истекло", "expires_date": "Истиче {date}", "explore": "Истражите", + "explorer": "Претраживач (Explorer)", "export": "Извези", "export_as_json": "Извези ЈСОН", "extension": "Екстензија (Extension)", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Главна фотографија је ажурирана", "featurecollection": "", + "features": "Функције", + "features_setting_description": "Управљајте функцијама апликације", "file_name": "Назив документа", "file_name_or_extension": "Име датотеке или екстензија", "filename": "Име датотеке", @@ -720,6 +768,8 @@ "filter_people": "Филтрирање особа", "find_them_fast": "Брзо их пронађите по имену помоћу претраге", "fix_incorrect_match": "Исправите нетачно подударање", + "folders": "Фасцикле (Folders)", + "folders_feature_description": "Прегледавање приказа фасцикле за фотографије и видео записе у систему датотека", "force_re-scan_library_files": "Принудно поново скенирајте све датотеке библиотеке", "forward": "Напред", "general": "Генерално", @@ -744,6 +794,15 @@ "hour": "Сат", "image": "Фотографија", "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} снимљено {date}", + "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}", "image_alt_text_people": "{count, plural, =1 {са {person1}} =2 {са {person1} и {person2}} =3 {са {person1}, {person2}, и {person3}} other {са {person1}, {person2}, и {others, number} остали}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Видео запис снимљен} other {Фотографија усликана}}", @@ -810,6 +869,7 @@ "license_trial_info_4": "Молимо вас да размислите о куповини лиценце за подршку континуираном развоју услуге", "light": "Светло", "like_deleted": "Лајкуј избрисано", + "link_motion_video": "Направи везу за видео запис", "link_options": "Опције везе", "link_to_oauth": "Веза до OAuth-a", "linked_oauth_account": "Повезани OAuth налог", @@ -828,6 +888,7 @@ "look": "Погледај", "loop_videos": "Понављајте видео записе", "loop_videos_description": "Омогућите за аутоматско понављање видео записа у прегледнику детаља.", + "main_branch_warning": "Употребљавате развојну верзију; строго препоручујемо употребу издате верзије!", "make": "Креирај", "manage_shared_links": "Управљајте дељеним везама", "manage_sharing_with_partners": "Управљајте дељењем са партнерима", @@ -864,6 +925,7 @@ "name": "Име", "name_or_nickname": "Име или надимак", "never": "Никада", + "new_album": "Нови албум", "new_api_key": "Нови АПИ кључ (key)", "new_password": "Нова шифра", "new_person": "Нова особа", @@ -896,18 +958,21 @@ "notifications": "Нотификације", "notifications_setting_description": "Управљајте обавештењима", "oauth": "OAuth", + "official_immich_resources": "Званични Имич ресурси", "offline": "Одсутан (Offline)", "offline_paths": "Недоступне (Offline) путање", "offline_paths_description": "Ови резултати могу бити последица ручног брисања датотека које нису део спољне библиотеке.", "ok": "Ок", "oldest_first": "Најстарије прво", "onboarding": "Приступање (Онбоардинг)", + "onboarding_privacy_description": "Следеће (опционе) функције се ослањају на спољне услуге и могу се онемогућити у било ком тренутку у подешавањима администрације.", "onboarding_theme_description": "Изаберите тему боја за свој налог. Ово можете касније да промените у подешавањима.", "onboarding_welcome_description": "Хајде да подесимо вашу инстанцу са неким уобичајеним подешавањима.", "onboarding_welcome_user": "Добродошли, {user}", "online": "Доступан (Онлине)", "only_favorites": "Само фаворити", "only_refreshes_modified_files": "Освежава само измењене датотеке", + "open_in_map_view": "Отвори у приказу мапе", "open_in_openstreetmap": "Отворите у ОпенСтреетМап-у", "open_the_search_filters": "Отворите филтере за претрагу", "options": "Опције", @@ -942,6 +1007,7 @@ "pending": "На чекању", "people": "Особе", "people_edits_count": "Измењено {count, plural, one {# особа} other {# особе}}", + "people_feature_description": "Прегледавање фотографија и видео снимака груписаних по особама", "people_sidebar_description": "Прикажите везу до особа на бочној траци", "perform_library_tasks": "", "permanent_deletion_warning": "Упозорење за трајно брисање", @@ -974,6 +1040,7 @@ "previous_memory": "Prethodno сећање", "previous_or_next_photo": "Prethodna или следећа фотографија", "primary": "Примарна (Primary)", + "privacy": "Приватност", "profile_image_of_user": "Слика профила од корисника {user}", "profile_picture_set": "Профилна слика постављена.", "public_album": "Јавни албум", @@ -1000,7 +1067,21 @@ "purchase_panel_info_1": "Изградња Имич-а захтева много времена и труда, а имамо инжењере који раде на томе са пуним радним временом како бисмо је учинили што је могуће бољом. Наша мисија је да софтвер отвореног кода и етичке пословне праксе постану одржив извор прихода за програмере и да створимо екосистем који поштује приватност са стварним алтернативама експлоатативним услугама у облаку.", "purchase_panel_info_2": "Пошто смо се обавезали да нећемо додавати платне зидове, ова куповина вам неће дати никакве додатне функције у Имич-у. Ослањамо се на кориснике попут вас да подрже Имич-ов стални развој.", "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": "Кључем производа сервера управља администратор", "range": "", + "rating": "Оцена звездица", + "rating_clear": "Обриши оцену", + "rating_count": "{count, plural, one {# звезда} other {# звезде}}", + "rating_description": "Прикажите EXIF оцену у инфо панелу", "raw": "", "reaction_options": "Опције реакције", "read_changelog": "Прочитајте дневник промена", @@ -1012,11 +1093,13 @@ "recent_searches": "Скорашње претраге", "refresh": "Освежи", "refresh_encoded_videos": "Освежите кодиране (енцодед) видео записе", + "refresh_faces": "Освежи лица", "refresh_metadata": "Освежите метаподатке", "refresh_thumbnails": "Освежите сличице", "refreshed": "Освежено", - "refreshes_every_file": "Освежава сваку датотеку", + "refreshes_every_file": "Поново чита све постојеће и нове датотеке", "refreshing_encoded_video": "Освежавање кодираног (енцодед) видеа", + "refreshing_faces": "Освежавањe лица", "refreshing_metadata": "Освежавање мета-података", "regenerating_thumbnails": "Обнављање сличица", "remove": "Уклони", @@ -1024,15 +1107,16 @@ "remove_assets_shared_link_confirmation": "Да ли сте сигурни да желите да уклоните {count, plural, one {# датотеку} other {# датотеке}} са ове дељене везе?", "remove_assets_title": "Уклонити датотеке?", "remove_custom_date_range": "Уклоните прилагођени период", + "remove_deleted_assets": "Уклоните ванмрежне (offline) датотеке", "remove_from_album": "Обриши из албума", "remove_from_favorites": "Уклони из фаворита", "remove_from_shared_link": "Уклоните са дељене везе", - "remove_offline_files": "Уклоните ванмрежне (offline) датотеке", "remove_user": "Уклони корисника", "removed_api_key": "Уклоњен АПИ кључ (key): {name}", "removed_from_archive": "Уклоњено из архиве", "removed_from_favorites": "Уклоњено из омиљених (фаворитес)", "removed_from_favorites_count": "{count, plural, other {Уклоњено #}} из омиљених", + "removed_tagged_assets": "Уклоњена ознака (tag) из {count, plural, one {# датотеке} other {# датотека}}", "rename": "Преименуј", "repair": "Поправи", "repair_no_results_message": "Овде ће се појавити датотеке које нису праћене и недостају", @@ -1045,6 +1129,7 @@ "reset_people_visibility": "Ресетујте видљивост особа", "reset_settings_to_default": "", "reset_to_default": "Ресетујте на подразумеване вредности", + "resolve_duplicates": "Реши дупликате", "resolved_all_duplicates": "Сви дупликати су разрешени", "restore": "Поврати", "restore_all": "Поврати све", @@ -1063,6 +1148,7 @@ "say_something": "Реци нешто", "scan_all_libraries": "Скенирај све библиотеке", "scan_all_library_files": "Поново скенирајте све датотеке библиотеке", + "scan_library": "Скенирај", "scan_new_library_files": "Скенирајте нове датотеке библиотеке", "scan_settings": "Подешавања скенирања", "scanning_for_album": "Скенирање албума...", @@ -1078,9 +1164,12 @@ "search_for_existing_person": "Потражите постојећу особу", "search_no_people": "Без особа", "search_no_people_named": "Нема особа са именом „{name}“", + "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", + "search_settings": "Претрага подешавања", "search_state": "Тражи регион...", + "search_tags": "Претражи ознаке (tags)...", "search_timezone": "Претражи временску зону...", "search_type": "Врста претраге", "search_your_photos": "Претражи своје фотографије", @@ -1089,6 +1178,7 @@ "see_all_people": "Види све особе", "select_album_cover": "Изаберите омот албума", "select_all": "Изабери све", + "select_all_duplicates": "Изаберите све дупликате", "select_avatar_color": "Изаберите боју аватара", "select_face": "Изаберите лице", "select_featured_photo": "Изаберите истакнуту фотографију", @@ -1121,6 +1211,7 @@ "shared_by_user": "Дели {user}", "shared_by_you": "Ви делите", "shared_from_partner": "Слике од {partner}", + "shared_link_options": "Опције дељене везе", "shared_links": "Дељене везе", "shared_photos_and_videos_count": "{assetCount, plural, other {# дељене фотографије и видео записе.}}", "shared_with_partner": "Дели се са {partner}", @@ -1129,6 +1220,7 @@ "sharing_sidebar_description": "Прикажите везу до Дељења на бочној траци", "shift_to_permanent_delete": "притисните ⇧ да трајно избришете датотеку", "show_album_options": "Прикажи опције албума", + "show_albums": "Прикажи албуме", "show_all_people": "Покажи све особе", "show_and_hide_people": "Откриј и сакриј особе", "show_file_location": "Прикажи локацију датотеке", @@ -1143,11 +1235,18 @@ "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": "Прескочи на ознаке (tags)", "slideshow": "Слајдови", "slideshow_settings": "Подешавања слајдова", "sort_albums_by": "Сортирај албуме по...", @@ -1159,6 +1258,8 @@ "sort_title": "Наслов", "source": "Извор", "stack": "Слагање", + "stack_duplicates": "Дупликати гомиле", + "stack_select_one_photo": "Изаберите једну главну фотографију за гомилу", "stack_selected_photos": "Сложите изабране фотографије", "stacked_assets_count": "Наслагано {count, plural, one {# датотека} other {# датотеке}}", "stacktrace": "Веза до гомиле", @@ -1176,27 +1277,41 @@ "submit": "Достави", "suggestions": "Сугестије", "sunrise_on_the_beach": "Излазак сунца на плажи", + "support": "Подршка", + "support_and_feedback": "Подршка и повратне информације", + "support_third_party_description": "Ваша иммицх инсталација је спакована од стране треће стране. Проблеми са којима се суочавате могу бити узроковани тим пакетом, па вас молимо да им прво поставите проблеме користећи доње везе.", "swap_merge_direction": "Замените правац спајања", "sync": "Синхронизација", + "tag": "Ознака (tag)", + "tag_assets": "Означите датотеке", + "tag_created": "Направљена ознака (tag): {tag}", + "tag_feature_description": "Прегледавање фотографија и видео снимака груписаних по логичним темама ознака", + "tag_not_found_question": "Не можете да пронађете ознаку (tag)? Направите нову ознаку", + "tag_updated": "Ажурирана ознака (tag): {tag}", + "tagged_assets": "Означено (tagged) {count, plural, one {# датотека} other {# датотеке}}", + "tags": "Ознаке (tags)", "template": "Шаблон (Темплате)", "theme": "Теме", "theme_selection": "Избор теме", "theme_selection_description": "Аутоматски поставите тему на светлу или тамну на основу системских преференција вашег претраживача", "they_will_be_merged_together": "Они ће бити спојени заједно", + "third_party_resources": "Ресурси трећих страна", "time_based_memories": "Сећања заснована на времену", "timezone": "Временска зона", "to_archive": "Архивирај", "to_change_password": "Промени лозинку", "to_favorite": "Постави као фаворит", "to_login": "Пријава", + "to_parent": "Врати се назад", + "to_root": "На почетак", "to_trash": "Смеће", "toggle_settings": "Намести подешавања", - "toggle_theme": "Намести теме", + "toggle_theme": "Намести тамну тему", "toggle_visibility": "Namesti vidljivost", "total_usage": "Укупна употреба", "trash": "Отпад", "trash_all": "Баци све у отпад", - "trash_count": "Отпад {count}", + "trash_count": "Отпад {count, number}", "trash_delete_asset": "Отпад/Избриши датотеку", "trash_no_results_message": "Слике и видео записи у отпаду ће се појавити овде.", "trashed_items_will_be_permanently_deleted_after": "Датотеке у отпаду ће бити трајно избрисане након {days, plural, one {# дан} few {# дана} other {# дана}}.", @@ -1210,12 +1325,15 @@ "unknown_album": "Nepoznat Album", "unknown_year": "Непозната Година", "unlimited": "Неограничено", + "unlink_motion_video": "Прекините везу са видео снимком", "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", "unnamed_album": "Неименовани албум", + "unnamed_album_delete_confirmation": "Да ли сте сигурни да желите да избришете овај албум?", "unnamed_share": "Неименовано делење", "unsaved_change": "Несачувана промена", "unselect_all": "Поништи све", + "unselect_all_duplicates": "Поништи избор свих дупликата", "unstack": "Разгомилај (Ун-стацк)", "unstacked_assets_count": "Несложено {count, plural, one {# датотека} other {# датотеке}}", "untracked_files": "Непраћене Датотеке", @@ -1225,7 +1343,7 @@ "upload": "Уплоадуј", "upload_concurrency": "Паралелно уплоадовање", "upload_errors": "Отпремање је завршено са {count, plural, one {# грешком} other {# грешака}}, освежите страницу да бисте видели нове датотеке за отпремање (уплоад).", - "upload_progress": "Преостало {remaining} – Обрађено {processed}/{total}", + "upload_progress": "Преостало {remaining, number} – Обрађено {processed, number}/{total, number}", "upload_skipped_duplicates": "Прескочено {count, plural, one {# дупла датотека} other {# дуплих датотека}}", "upload_status_duplicates": "Дупликати", "upload_status_errors": "Грешке", @@ -1239,6 +1357,8 @@ "user_license_settings": "Лиценца", "user_license_settings_description": "Управљајте својом лиценцом", "user_liked": "{user} је лајковао {type, select, photo {ову фотографију} video {овај видео запис} asset {ову датотеку} other {ово}}", + "user_purchase_settings": "Куповина", + "user_purchase_settings_description": "Управљајте куповином", "user_role_set": "Постави {user} као {role}", "user_usage_detail": "Детаљи коришћења корисника", "username": "Корисничко име", @@ -1249,6 +1369,8 @@ "version": "Верзија", "version_announcement_closing": "Твој пријатељ, Алекс", "version_announcement_message": "Здраво пријатељу, постоји нова верзија апликације, молимо вас да одвојите време да посетите напомене о издању и уверите се у своје docker-compose.yml, и .env подешавање је ажурирано како би се спречиле било какве погрешне конфигурације, посебно ако користите WatchTower или било који механизам који аутоматски управља ажурирањем ваше апликације.", + "version_history": "Историја верзија", + "version_history_item": "Инсталирано {version} on {date}", "video": "Видео запис", "video_hover_setting": "Пусти сличицу видеа када лебди", "video_hover_setting_description": "Пусти сличицу видеа када миш пређе преко ставке. Чак и када је oneмогућена, репродукција се може покренути преласком миша преко икone за репродукцију.", @@ -1258,6 +1380,7 @@ "view_album": "Погледај албум", "view_all": "Прикажи Све", "view_all_users": "Прикажи све кориснике", + "view_in_timeline": "Прикажи у временској линији", "view_links": "Прикажи везе", "view_next_asset": "Погледајте следећу датотеку", "view_previous_asset": "Погледај претходну датотеку", @@ -1268,7 +1391,7 @@ "warning": "Упозорење", "week": "Недеља", "welcome": "Добродошли", - "welcome_to_immich": "Добродошли у immich", + "welcome_to_immich": "Добродошли у Имич (Immich)", "year": "Година", "years_ago": "пре {years, plural, one {# године} other {# година}}", "yes": "Да", diff --git a/web/src/lib/i18n/sr_Latn.json b/i18n/sr_Latn.json similarity index 90% rename from web/src/lib/i18n/sr_Latn.json rename to i18n/sr_Latn.json index eb9320ae48..f871cb12b0 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -25,9 +25,10 @@ "add_to_shared_album": "Dodaj u deljen album", "added_to_archive": "Dodato u arhivu", "added_to_favorites": "Dodato u favorite", - "added_to_favorites_count": "Dodato {count} u favorite", + "added_to_favorites_count": "Dodato {count, number} u favorite", "admin": { "add_exclusion_pattern_description": "Dodajte obrasce isključenja. Korištenje *, ** i ? je podržano. Da biste ignorisali sve datoteke u bilo kom direktorijumu pod nazivom „Rav“, koristite „**/Rav/**“. Da biste ignorisali sve datoteke koje se završavaju na „.tif“, koristite „**/*.tif“. Da biste ignorisali apsolutnu putanju, koristite „/path/to/ignore/**“.", + "asset_offline_description": "Ovo eksterno bibliotečko sredstvo se više ne nalazi na disku i premešteno je u smeće. Ako je datoteka premeštena unutar biblioteke, proverite svoju vremensku liniju za novo odgovarajuće sredstvo. Da biste vratili ovo sredstvo, uverite se da Immich može da pristupi dole navedenoj putanji datoteke i skenirajte biblioteku.", "authentication_settings": "Podešavanja za autentifikaciju", "authentication_settings_description": "Upravljajte lozinkom, OAuth-om i drugim podešavanjima autentifikacije", "authentication_settings_disable_all": "Da li ste sigurni da želite da onemogućite sve metode prijavljivanja? Prijava će biti potpuno onemogućena.", @@ -41,6 +42,7 @@ "confirm_email_below": "Da biste potvrdili, unesite \"{email}\" ispod", "confirm_reprocess_all_faces": "Da li ste sigurni da želite da ponovo obradite sva lica? Ovo će takođe obrisati imenovane osobe.", "confirm_user_password_reset": "Da li ste sigurni da želite da resetujete lozinku korisnika {user}?", + "create_job": "Kreirajte posao", "crontab_guru": "Guru servisnih zadataka", "disable_login": "Onemogući prijavu", "disabled": "", @@ -49,27 +51,37 @@ "external_library_created_at": "Eksterna biblioteka (napravljena {date})", "external_library_management": "Upravljanje eksternim bibliotekama", "face_detection": "Detekcija lica", - "face_detection_description": "Otkrivanje lica u datotekama pomoću mašinskog učenja. Za video snimke se uzima u obzir samo sličica. „Sve“ (ponovno) obrađuje sve datoteke. „Nedostaju“ sredstva u nizu koja još nisu obrađena. Otkrivena lica će biti stavljena u red za prepoznavanje lica nakon što se prepoznavanje lica završi, grupišući ih u postojeće ili nove ljude.", - "facial_recognition_job_description": "Grupa je detektovala lica i dodala ih postojecim ljudima. Ovaj korak se pokreće nakon što je prepoznavanje lica završeno. „Sve“ (ponovno) grupiše sva lica. „Nedostaju“ lica u redovima kojima nije dodeljena osoba.", + "face_detection_description": "Otkrijte lica u datotekama pomoću mašinskog učenja. Za video snimke se uzima u obzir samo sličica. „Osveži“ (ponovno) obrađuje sve datoteke. „Resetovanje“ dodatno briše sve trenutne podatke o licu. „Nedostaju“ datoteke u redu koje još nisu obrađene. Otkrivena lica će biti stavljena u red za prepoznavanje lica nakon što se prepoznavanje lica završi, grupišući ih u postojeće ili nove osobe.", + "facial_recognition_job_description": "Grupa je detektovala lica i dodala ih postojećim osobama. Ovaj korak se pokreće nakon što je prepoznavanje lica završeno. „Resetuj“ (ponovno) grupiše sva lica. „Nedostaju“ lica u redovima kojima nije dodeljena osoba.", "failed_job_command": "Komanda {command} nije uspela za posao: {job}", "force_delete_user_warning": "UPOZORENJE: Ovo će odmah ukloniti korisnika i sve datoteke. Ovo se ne može opozvati i datoteke se ne mogu oporaviti.", "forcing_refresh_library_files": "Prinudno osvežavanje svih datoteka biblioteke", + "image_format": "Format", "image_format_description": "WebP proizvodi manje datoteke od JPEG, ali se sporije kodira.", "image_prefer_embedded_preview": "Preferirajte ugrađeni pregled", "image_prefer_embedded_preview_setting_description": "Koristite ugrađene preglede u RAW fotografije kao ulaz za obradu slike kada su dostupne. Ovo može da proizvede preciznije boje za neke slike, ali kvalitet pregleda zavisi od kamere i slika može imati više nepravilnosti kompresije.", "image_prefer_wide_gamut": "Preferirajte širok spektar", "image_prefer_wide_gamut_setting_description": "Koristite Display P3 za sličice. Ovo bolje čuva živopisnost slika sa širokim prostorima boja, ali slike mogu izgledati drugačije na starim uređajima sa starom verzijom pretraživača. sRGB slike se čuvaju kao sRGB da bi se izbegle promene boja.", + "image_preview_description": "Slika srednje veličine sa uklonjenim metapodacima, koja se koristi prilikom pregleda jednog elementa i za mašinsko učenje", "image_preview_format": "Pregled formata", + "image_preview_quality_description": "Kvalitet pregleda od 1-100. Više je bolje, ali proizvodi veće datoteke i može smanjiti odziv aplikacije. Postavljanje niske vrednosti može uticati na kvalitet mašinskog učenja.", "image_preview_resolution": "Pregled rezolucije", "image_preview_resolution_description": "Koristi se za gledanje jedne fotografije i za mašinsko učenje. Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje brzinu aplikacije.", + "image_preview_title": "Podešavanja pregleda", "image_quality": "Kvalitet", "image_quality_description": "Kvalitet slike od 1-100. Više je bolje za kvalitet, ali proizvodi veće datoteke, ova opcija utiče na pregled i sličice.", + "image_resolution": "Rezolucija", + "image_resolution_description": "Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje odziv aplikacije.", "image_settings": "Podešavanja slike", "image_settings_description": "Upravljajte kvalitetom i rezolucijom generisanih slika", + "image_thumbnail_description": "Mala sličica sa ogoljenim metapodacima, koja se koristi prilikom pregleda grupa fotografija kao što je glavna vremenska linija", "image_thumbnail_format": "Format sličice", + "image_thumbnail_quality_description": "Kvalitet sličica od 1-100. Više je bolje, ali proizvodi veće datoteke i može smanjiti odziv aplikacije.", "image_thumbnail_resolution": "Rezolucija sličice", "image_thumbnail_resolution_description": "Koristi se prilikom pregleda grupa fotografija (glavna vremenska linija, prikaz albuma, itd.). Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje brzinu aplikacije.", + "image_thumbnail_title": "Podešavanja sličica", "job_concurrency": "{job} paralelnost", + "job_created": "Posao kreiran", "job_not_concurrency_safe": "Ovaj posao nije bezbedan da bude paralelno aktivan.", "job_settings": "Podešavanja posla", "job_settings_description": "Upravljajte paralelnošću poslova", @@ -129,6 +141,7 @@ "map_enable_description": "Omogućite karakteristike mape", "map_gps_settings": "Map & GPS podešavanja", "map_gps_settings_description": "Upravljajte postavkama mape i GPS-a (obrnuto geokodiranje)", + "map_implications": "Funkcija mape se oslanja na eksternu uslugu pločica (tiles.immich.cloud)", "map_light_style": "Svetli stil", "map_manage_reverse_geocoding_settings": "Upravljajte podešavanjima Obrnuto geokodiranje", "map_reverse_geocoding": "Obrnuto geokodiranje", @@ -138,7 +151,11 @@ "map_settings_description": "Upravljajte podešavanjima mape", "map_style_description": "URL do style.json mape tema izgleda", "metadata_extraction_job": "Izvod metapodataka", - "metadata_extraction_job_description": "Izvucite informacije o metapodacima iz svake datoteke, kao što su GPS i rezolucija", + "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", + "metadata_faces_import_setting_description": "Dodajte lica iz EXIF podataka slike i sličnih metapodataka", + "metadata_settings": "Podešavanje metapodataka", + "metadata_settings_description": "Upravljajte podešavanjima metapodataka", "migration_job": "Migracije", "migration_job_description": "Prenesite sličice datoteka i lica u najnoviju strukturu direktorijuma", "no_paths_added": "Nema dodatih putanja", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "NAPOMENA: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Sa adrese", - "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", + "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", "notification_email_host_description": "Host servera e-pošte (npr. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Zanemarite greške sertifikata", "notification_email_ignore_certificate_errors_description": "Ignorišite greške u validaciji TLS sertifikata (ne preporučuje se)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL izdavača", "oauth_mobile_redirect_uri": "URI za preusmeravanje mobilnih uređaja", "oauth_mobile_redirect_uri_override": "Zamena URI-ja mobilnog preusmeravanja", - "oauth_mobile_redirect_uri_override_description": "Omogući kada je 'app.immich:/' nevažeći URI za preusmeravanje.", + "oauth_mobile_redirect_uri_override_description": "Omogući kada OAuth dobavljač (provider) ne dozvoljava mobilni URI, kao što je '{callback}'", "oauth_profile_signing_algorithm": "Algoritam za potpisivanje profila", "oauth_profile_signing_algorithm_description": "Algoritam koji se koristi za potpisivanje korisničkog profila.", "oauth_scope": "Obim", @@ -193,19 +210,22 @@ "password_settings": "Lozinka za prijavu", "password_settings_description": "Upravljajte podešavanjima za prijavu lozinkom", "paths_validated_successfully": "Sve putanje su uspešno potvrđene", + "person_cleanup_job": "Čišćenje osoba", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvežavanje svih biblioteka", "registration": "Registracija administratora", "registration_description": "Pošto ste prvi korisnik na sistemu, bićete dodeljeni kao Admin i odgovorni ste za administrativne zadatke, a dodatne korisnike ćete kreirati vi.", - "removing_offline_files": "Uklanjanje vanmrežnih datoteka", + "removing_deleted_files": "Uklanjanje vanmrežnih datoteka", "repair_all": "Popravi sve", "repair_matched_items": "Poklapa se sa {count, plural, one {1 stavkom} few {# stavke} other {# stavki}}", "repaired_items": "{count, plural, one {Popravljena 1 stavka} few {Popravljene # stavke} other {Popravljene # stavki}}", "require_password_change_on_login": "Zahtevati od korisnika da promeni lozinku pri prvom prijavljivanju", "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", "scanning_library_for_changed_files": "Skeniranje biblioteke za promenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "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)://", @@ -233,6 +253,7 @@ "storage_template_settings_description": "Upravljajte strukturom direktorijuma i imenom datoteke sredstva za otpremanje", "storage_template_user_label": "{label} je oznaka za skladištenje korisnika", "system_settings": "Podešavanja sistema", + "tag_cleanup_job": "Čišćenje oznaka (tags)", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućavaju prilagođavanje dizajna Immich-a.", "theme_settings": "Podešavanje tema", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Hardversko ubrzanje", "transcoding_hardware_acceleration_description": "Ekperimentalno; mnogo brže, ali će imati niži kvalitet pri istoj brzini prenosa", "transcoding_hardware_decoding": "Hardversko dekodiranje", - "transcoding_hardware_decoding_setting_description": "Odnosi se samo na NVENC, QSV i RKMPP. Omogućava ubrzanje od kraja do kraja umesto da samo ubrzava kodiranje. Možda neće raditi na svim video snimcima.", + "transcoding_hardware_decoding_setting_description": "Omogućava ubrzanje od kraja do kraja umesto da samo ubrzava kodiranje. Možda neće raditi na svim video snimcima.", "transcoding_hevc_codec": "HEVC kodek", "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.", @@ -278,7 +299,7 @@ "transcoding_preferred_hardware_device": "Željeni hardverski uređaj", "transcoding_preferred_hardware_device_description": "Odnosi se samo na VAAPI i QSV. Postavlja dri node koji se koristi za hardversko transkodiranje.", "transcoding_preset_preset": "Unapred podešena podešavanja (-preset)", - "transcoding_preset_preset_description": "Brzina kompresije. Sporije unapred podešene vrednosti proizvode manje datoteke i povećavaju kvalitet kada ciljate određenu brzinu prenosa. VP9 ignoriše brzine iznad `brže`.", + "transcoding_preset_preset_description": "Brzina kompresije. Sporije unapred podešene vrednosti proizvode manje datoteke i povećavaju kvalitet kada ciljate određenu brzinu prenosa. VP9 ignoriše brzine iznad 'brže'.", "transcoding_reference_frames": "Referentni okviri (frames)", "transcoding_reference_frames_description": "Broj okvira (frames) za referencu prilikom kompresije datog okvira. Više vrednosti poboljšavaju efikasnost kompresije, ali usporavaju kodiranje. 0 automatski postavlja ovu vrednost.", "transcoding_required_description": "Samo video snimci koji nisu u prihvaćenom formatu", @@ -307,6 +328,7 @@ "trash_settings_description": "Upravljajte podešavanjima smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. One mogu nastati zbog neuspešnih premeštenja, zbog prekinutih otpremanja ili kao preostatak zbog greške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Nalog i datoteke {user} biće zakazani za trajno brisanje za {delay, plural, one {# dan} other {# dana}}.", "user_delete_delay_settings": "Izbriši uz kašnjenje", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog naloga i datoteka. Posao brisanja korisnika se pokreće u ponoć da bi se proverili korisnici koji su spremni za brisanje. Promene ove postavke će biti procenjene pri sledećem izvršenju.", @@ -320,7 +342,8 @@ "user_settings": "Podešavanja korisnika", "user_settings_description": "Upravljajte korisničkim podešavanjima", "user_successfully_removed": "Korisnik {email} je uspešno uklonjen.", - "version_check_enabled_description": "Omogućite periodične zahteve GitHub-u za proveru novih izdanja", + "version_check_enabled_description": "Omogućite proveru novih izdanja", + "version_check_implications": "Funkcija provere verzije se oslanja na periodičnu komunikaciju sa github.com", "version_check_settings": "Provera verzije", "version_check_settings_description": "Omogućite/onemogućite obaveštenje o novoj verziji", "video_conversion_job": "Transkodiranje video zapisa", @@ -336,7 +359,8 @@ "album_added": "Album dodan", "album_added_notification_setting_description": "Primi obaveštenje e-poštom kad budeš dodan u deljen album", "album_cover_updated": "Omot albuma ažuriran", - "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?\nAko se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", + "album_delete_confirmation": "Da li stvarno želite da izbrišete album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album deli, drugi korisnici više neće moći da mu pristupe.", "album_info_updated": "Informacija albuma ažurirana", "album_leave": "Napustiti album?", "album_leave_confirmation": "Da li stvarno želite da napustite {album}?", @@ -360,6 +384,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)", + "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.", "api_key_empty": "Ime vašeg API ključa ne bi trebalo da bude prazno", @@ -381,8 +406,9 @@ "asset_has_unassigned_faces": "Datoteka ima nedodeljena lica", "asset_hashing": "Heširanje...", "asset_offline": "Datoteka odsutna", - "asset_offline_description": "Ova datoteka je van mreže. Immich ne može da pristupi lokaciji svoje datoteke. Uverite se da je datoteka dostupna, a zatim ponovo skenirajte biblioteku.", + "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...", "assets": "Zapisi", @@ -394,7 +420,7 @@ "assets_moved_to_trash_count": "Premešteno {count, plural, one {# datoteka} few {# datoteke} other {# datoteka}} u otpad", "assets_permanently_deleted_count": "Trajno izbrisano {count, plural, one {# datoteka} few {# datoteke} other {# datoteka}}", "assets_removed_count": "Uklonjeno {count, plural, one {# datoteka} few {# datoteke} other {# datoteka}}", - "assets_restore_confirmation": "Da li ste sigurni da želite da vratite sve svoje datoteke koje su u otpadu? Ne možete poništiti ovu radnju!", + "assets_restore_confirmation": "Da li ste sigurni da želite da vratite sve svoje datoteke koje su u otpadu? Ne možete poništiti ovu radnju! Imajte na umu da se vanmrežna sredstva ne mogu vratiti na ovaj način.", "assets_restored_count": "Vraćeno {count, plural, one {# datoteka} few {# datoteke} other {# datoteka}}", "assets_trashed_count": "Bačeno u otpad {count, plural, one {# datoteka} few{# datoteke} other {# datoteka}}", "assets_were_part_of_album_count": "{count, plural, one {Datoteka je} other {Datoteke su}} već deo albuma", @@ -405,7 +431,8 @@ "birthdate_saved": "Datum rođenja uspešno sačuvan", "birthdate_set_description": "Datum rođenja se koristi da bi se izračunale godine ove osobe u dobu određene fotografije.", "blurred_background": "Zamućena pozadina", - "build": "Sagradi (Build)", + "bugs_and_feature_requests": "Greške (bugs) i zahtevi za funkcije", + "build": "Pod-verzija (Build)", "build_image": "Sagradi (Build) image", "bulk_delete_duplicates_confirmation": "Da li ste sigurni da želite grupno da izbrišete {count, plural, one {# dupliran elemenat} few {# duplirana elementa} other {# dupliranih elemenata}}? Ovo će zadržati najveće sredstvo svake grupe i trajno izbrisati sve druge duplikate. Ne možete poništiti ovu radnju!", "bulk_keep_duplicates_confirmation": "Da li ste sigurni da želite da zadržite {count, plural, one {1 dupliranu datoteku} few {# duplirane datoteke} other {# dupliranih datoteka}}? Ovo će rešiti sve duplirane grupe bez brisanja bilo čega.", @@ -441,9 +468,11 @@ "clear_all_recent_searches": "Obrišite sve nedavne pretrage", "clear_message": "Obriši poruku", "clear_value": "Jasna vrednost", + "clockwise": "U smeru kazaljke", "close": "Zatvori", "collapse": "Skupi", "collapse_all": "Skupi sve", + "color": "Boja", "color_theme": "Režim boja", "comment_deleted": "Komentar obrisan", "comment_options": "Opcije komentara", @@ -477,6 +506,8 @@ "create_new_person": "Napravi novu osobu", "create_new_person_hint": "Dodelite izabrane datoteke novoj osobi", "create_new_user": "Napravi novog korisnika", + "create_tag": "Kreirajte oznaku (tag)", + "create_tag_description": "Napravite novu oznaku (tag). Za ugnežđene oznake, unesite punu putanju oznake uključujući kose crte.", "create_user": "Napravi korisnika", "created": "Napravljen", "current_device": "Trenutni uređaj", @@ -500,13 +531,17 @@ "delete_library": "Obriši biblioteku", "delete_link": "Obriši vezu", "delete_shared_link": "Obriši deljenu vezu", + "delete_tag": "Obriši oznaku (tag)", + "delete_tag_confirmation_prompt": "Da li stvarno želite da izbrišete oznaku {tagName}?", "delete_user": "Obriši korisnika", "deleted_shared_link": "Obrišena deljena veza", + "deletes_missing_assets": "Briše sredstva koja nedostaju sa diska", "description": "Opis", "details": "Detalji", "direction": "Smer", "disabled": "Onemogućeno", "disallow_edits": "Zabrani izmene", + "discord": "Diskord", "discover": "Otkrijte", "dismiss_all_errors": "Odbacite sve greške", "dismiss_error": "Odbaci grešku", @@ -515,8 +550,11 @@ "display_original_photos": "Prikažite originalne fotografije", "display_original_photos_setting_description": "Radije prikazujete originalnu fotografiju kada gledate materijal nego sličice kada je originalno delo kompatibilno sa webom. Ovo može dovesti do sporijeg prikaza fotografija.", "do_not_show_again": "Ne prikaži ponovo ovu poruku", + "documentation": "Dokumentacija", "done": "Urađeno", "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni video snimci", + "download_include_embedded_motion_videos_description": "Uključite video zapise ugrađene u fotografije u pokretu kao zasebnu datoteku", "download_settings": "Preuzimanje", "download_settings_description": "Upravljajte podešavanjima vezanim za preuzimanje datoteka", "downloading": "Preuzimanje u toku", @@ -546,10 +584,15 @@ "edit_location": "Uredi lokaciju", "edit_name": "Uredi ime", "edit_people": "Uredi osobe", + "edit_tag": "Uredi oznaku (tag)", "edit_title": "Uredi titulu", "edit_user": "Uredi korisnika", "edited": "Uređeno", "editor": "Urednik", + "editor_close_without_save_prompt": "Promene neće biti sačuvane", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Proporcije (aspect ratios)", + "editor_crop_tool_h2_rotation": "Rotacija", "email": "E-pošta", "empty": "", "empty_album": "Isprazni album", @@ -639,6 +682,7 @@ "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", "unable_to_get_shared_link": "Preuzimanje deljene veze nije uspelo", "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati video sa slikom", "unable_to_link_oauth_account": "Nije moguće povezati OAuth nalog", "unable_to_load_album": "Nije moguće učitati album", "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstava", @@ -655,8 +699,8 @@ "unable_to_remove_api_key": "Nije moguće ukloniti API ključ (key)", "unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti elemente sa deljenog linka", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Nije moguće ukloniti vanmrežne datoteke", "unable_to_remove_library": "Nije moguće ukloniti biblioteku", - "unable_to_remove_offline_files": "Nije moguće ukloniti vanmrežne datoteke", "unable_to_remove_partner": "Nije moguće ukloniti partnera", "unable_to_remove_reaction": "Nije moguće ukloniti reakciju", "unable_to_remove_user": "", @@ -679,6 +723,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", + "unable_to_unlink_motion_video": "Nije moguće odvezati video od slike", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -699,6 +744,7 @@ "expired": "Isteklo", "expires_date": "Ističe {date}", "explore": "Istražite", + "explorer": "Pretraživač (Explorer)", "export": "Izvezi", "export_as_json": "Izvezi JSON", "extension": "Ekstenzija (Extension)", @@ -712,6 +758,8 @@ "feature": "", "feature_photo_updated": "Glavna fotografija je ažurirana", "featurecollection": "", + "features": "Funkcije (features)", + "features_setting_description": "Upravljajte funkcijama aplikacije", "file_name": "Naziv dokumenta", "file_name_or_extension": "Ime datoteke ili ekstenzija", "filename": "Ime datoteke", @@ -720,6 +768,8 @@ "filter_people": "Filtriranje osoba", "find_them_fast": "Brzo ih pronađite po imenu pomoću pretrage", "fix_incorrect_match": "Ispravite netačno podudaranje", + "folders": "Fascikle (Folders)", + "folders_feature_description": "Pregledavanje prikaza fascikle za fotografije i video zapisa u sistemu datoteka", "force_re-scan_library_files": "Prinudno ponovo skenirajte sve datoteke biblioteke", "forward": "Napred", "general": "Generalno", @@ -819,6 +869,7 @@ "license_trial_info_4": "Molimo vas da razmislite o kupovini licence za podršku kontinuiranom razvoju usluge", "light": "Svetlo", "like_deleted": "Lajkuj izbrisano", + "link_motion_video": "Napravi vezu za video zapis", "link_options": "Opcije veze", "link_to_oauth": "Veza do OAuth-a", "linked_oauth_account": "Povezani OAuth nalog", @@ -837,6 +888,7 @@ "look": "Pogledaj", "loop_videos": "Ponavljajte video zapise", "loop_videos_description": "Omogućite za automatsko ponavljanje video zapisa u pregledniku detalja.", + "main_branch_warning": "Upotrebljavate razvojnu verziju; strogo preporučujemo upotrebu izdate verzije!", "make": "Kreiraj", "manage_shared_links": "Upravljajte deljenim vezama", "manage_sharing_with_partners": "Upravljajte deljenjem sa partnerima", @@ -873,6 +925,7 @@ "name": "Ime", "name_or_nickname": "Ime ili nadimak", "never": "Nikada", + "new_album": "Novi Album", "new_api_key": "Novi API ključ (key)", "new_password": "Nova šifra", "new_person": "Nova osoba", @@ -905,18 +958,21 @@ "notifications": "Notifikacije", "notifications_setting_description": "Upravljajte obaveštenjima", "oauth": "OAuth", + "official_immich_resources": "Zvanični Immich resursi", "offline": "Odsutan (Offline)", "offline_paths": "Nedostupne (Offline) putanje", "offline_paths_description": "Ovi rezultati mogu biti posledica ručnog brisanja datoteka koje nisu deo spoljne biblioteke.", "ok": "Ok", "oldest_first": "Najstarije prvo", "onboarding": "Pristupanje (Onboarding)", + "onboarding_privacy_description": "Sledeće (opcione) funkcije se oslanjaju na spoljne usluge i mogu se onemogućiti u bilo kom trenutku u podešavanjima administracije.", "onboarding_theme_description": "Izaberite temu boja za svoj nalog. Ovo možete kasnije da promenite u podešavanjima.", "onboarding_welcome_description": "Hajde da podesimo vašu instancu sa nekim uobičajenim podešavanjima.", "onboarding_welcome_user": "Dobrodošli, {user}", "online": "Dostupan (Online)", "only_favorites": "Samo favoriti", "only_refreshes_modified_files": "Osvežava samo izmenjene datoteke", + "open_in_map_view": "Otvorite u prikaz karte", "open_in_openstreetmap": "Otvorite u OpenStreetMap-u", "open_the_search_filters": "Otvorite filtere za pretragu", "options": "Opcije", @@ -951,6 +1007,7 @@ "pending": "Na čekanju", "people": "Osobe", "people_edits_count": "Izmenjeno {count, plural, one {# osoba} other {# osobe}}", + "people_feature_description": "Pregledavanje fotografija i video snimaka grupisanih po osobama", "people_sidebar_description": "Prikažite vezu do osoba na bočnoj traci", "perform_library_tasks": "", "permanent_deletion_warning": "Upozorenje za trajno brisanje", @@ -983,6 +1040,7 @@ "previous_memory": "Prethodno sećanje", "previous_or_next_photo": "Prethodna ili sledeća fotografija", "primary": "Primarna (Primary)", + "privacy": "Privatnost", "profile_image_of_user": "Slika profila od korisnika {user}", "profile_picture_set": "Profilna slika postavljena.", "public_album": "Javni album", @@ -1020,6 +1078,10 @@ "purchase_server_title": "Server", "purchase_settings_server_activated": "Ključem proizvoda servera upravlja administrator", "range": "", + "rating": "Ocena zvezdica", + "rating_clear": "Obriši ocenu", + "rating_count": "{count, plural, one {# zvezda} other {# zvezde}}", + "rating_description": "Prikažite EXIF ocenu u info panelu", "raw": "", "reaction_options": "Opcije reakcije", "read_changelog": "Pročitajte dnevnik promena", @@ -1031,11 +1093,13 @@ "recent_searches": "Skorašnje pretrage", "refresh": "Osveži", "refresh_encoded_videos": "Osvežite kodirane (encoded) video zapise", + "refresh_faces": "Osveži lica", "refresh_metadata": "Osvežite metapodatke", "refresh_thumbnails": "Osvežite sličice", "refreshed": "Osveženo", - "refreshes_every_file": "Osvežava svaku datoteku", + "refreshes_every_file": "Ponovo čita sve postojeće i nove datoteke", "refreshing_encoded_video": "Osvežavanje kodiranog (encoded) videa", + "refreshing_faces": "Osvežavanje lica", "refreshing_metadata": "Osvežavanje meta-podataka", "regenerating_thumbnails": "Obnavljanje sličica", "remove": "Ukloni", @@ -1043,15 +1107,16 @@ "remove_assets_shared_link_confirmation": "Da li ste sigurni da želite da uklonite {count, plural, one {# datoteku} other {# datoteke}} sa ove deljene veze?", "remove_assets_title": "Ukloniti datoteke?", "remove_custom_date_range": "Uklonite prilagođeni period", + "remove_deleted_assets": "Uklonite vanmrežne (offline) datoteke", "remove_from_album": "Obriši iz albuma", "remove_from_favorites": "Ukloni iz favorita", "remove_from_shared_link": "Uklonite sa deljene veze", - "remove_offline_files": "Uklonite vanmrežne (offline) datoteke", "remove_user": "Ukloni korisnika", "removed_api_key": "Uklonjen API ključ (key): {name}", "removed_from_archive": "Uklonjeno iz arhive", "removed_from_favorites": "Uklonjeno iz omiljenih (favorites)", "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", "rename": "Preimenuj", "repair": "Popravi", "repair_no_results_message": "Ovde će se pojaviti datoteke koje nisu praćene i nedostaju", @@ -1083,6 +1148,7 @@ "say_something": "Reci nešto", "scan_all_libraries": "Skeniraj sve biblioteke", "scan_all_library_files": "Ponovo skenirajte sve datoteke biblioteke", + "scan_library": "Skeniraj", "scan_new_library_files": "Skenirajte nove datoteke biblioteke", "scan_settings": "Podešavanja skeniranja", "scanning_for_album": "Skeniranje albuma...", @@ -1098,9 +1164,12 @@ "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_settings": "Pretraga podešavanja", "search_state": "Traži region...", + "search_tags": "Pretraži oznake (tags)...", "search_timezone": "Pretraži vremensku zonu...", "search_type": "Vrsta pretrage", "search_your_photos": "Pretraži svoje fotografije", @@ -1142,6 +1211,7 @@ "shared_by_user": "Deli {user}", "shared_by_you": "Vi delite", "shared_from_partner": "Slike od {partner}", + "shared_link_options": "Opcije deljene veze", "shared_links": "Deljene veze", "shared_photos_and_videos_count": "{assetCount, plural, other {# deljene fotografije i video zapise.}}", "shared_with_partner": "Deli se sa {partner}", @@ -1150,6 +1220,7 @@ "sharing_sidebar_description": "Prikažite vezu do Deljenja na bočnoj traci", "shift_to_permanent_delete": "pritisnite ⇧ da trajno izbrišete datoteku", "show_album_options": "Prikaži opcije albuma", + "show_albums": "Prikaži albume", "show_all_people": "Pokaži sve osobe", "show_and_hide_people": "Otkrij i sakrij osobe", "show_file_location": "Prikaži lokaciju datoteke", @@ -1164,13 +1235,18 @@ "show_person_options": "Prikaži opcije osobe", "show_progress_bar": "Prikaži traku napretka", "show_search_options": "Prikaži opcije pretrage", + "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", "shuffle": "Mešanje", + "sidebar": "Bočna traka", + "sidebar_display_description": "Prikažite vezu do prikaza na bočnoj traci", "sign_out": "Odjava", "sign_up": "Prijavi se", "size": "Veličina", "skip_to_content": "Pređi na sadržaj", + "skip_to_folders": "Preskoči do mapa (folders)", + "skip_to_tags": "Preskoči do oznaka (tags)", "slideshow": "Slajdovi", "slideshow_settings": "Podešavanja slajdova", "sort_albums_by": "Sortiraj albume po...", @@ -1182,6 +1258,8 @@ "sort_title": "Naslov", "source": "Izvor", "stack": "Slaganje", + "stack_duplicates": "Duplikati gomile", + "stack_select_one_photo": "Izaberite jednu glavnu fotografiju za gomilu", "stack_selected_photos": "Složite izabrane fotografije", "stacked_assets_count": "Naslagano {count, plural, one {# datoteka} other {# datoteke}}", "stacktrace": "Veza do gomile", @@ -1199,27 +1277,41 @@ "submit": "Dostavi", "suggestions": "Sugestije", "sunrise_on_the_beach": "Izlazak sunca na plaži", + "support": "Podrška", + "support_and_feedback": "Podrška i povratne informacije", + "support_third_party_description": "Vaša immich instalacija je spakovana od strane treće strane. Problemi sa kojima se suočavate mogu biti uzrokovani tim paketom, pa vas molimo da im prvo postavite probleme koristeći donje veze.", "swap_merge_direction": "Zamenite pravac spajanja", "sync": "Sinhronizacija", + "tag": "Oznaka (tag)", + "tag_assets": "Označite (tag) sredstva", + "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_updated": "Ažurirana oznaka (tag): {tag}", + "tagged_assets": "Označeno (tagged) {count, plural, one {# datoteka} other {# datoteke}}", + "tags": "Oznake (tags)", "template": "Šablon (Template)", "theme": "Teme", "theme_selection": "Izbor teme", "theme_selection_description": "Automatski postavite temu na svetlu ili tamnu na osnovu sistemskih preferencija vašeg pretraživača", "they_will_be_merged_together": "Oni će biti spojeni zajedno", + "third_party_resources": "Resursi trećih strana", "time_based_memories": "Sećanja zasnovana na vremenu", "timezone": "Vremenska zona", "to_archive": "Arhiviraj", "to_change_password": "Promeni lozinku", "to_favorite": "Postavi kao favorit", "to_login": "Prijava", + "to_parent": "Vrati se nazad", + "to_root": "Na početak", "to_trash": "Smeće", "toggle_settings": "Namesti podešavanja", - "toggle_theme": "Namesti teme", + "toggle_theme": "Namesti tamnu temu", "toggle_visibility": "Namesti vidljivost", "total_usage": "Ukupna upotreba", "trash": "Otpad", "trash_all": "Baci sve u otpad", - "trash_count": "Otpad {count}", + "trash_count": "Otpad {count, number}", "trash_delete_asset": "Otpad/Izbriši datoteku", "trash_no_results_message": "Slike i video zapisi u otpadu će se pojaviti ovde.", "trashed_items_will_be_permanently_deleted_after": "Datoteke u otpadu će biti trajno izbrisane nakon {days, plural, one {# dan} few {# dana} other {# dana}}.", @@ -1233,9 +1325,11 @@ "unknown_album": "Nepoznat Album", "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", "unnamed_album": "Neimenovani album", + "unnamed_album_delete_confirmation": "Da li ste sigurni da želite da izbrišete ovaj album?", "unnamed_share": "Neimenovano delenje", "unsaved_change": "Nesačuvana promena", "unselect_all": "Poništi sve", @@ -1249,7 +1343,7 @@ "upload": "Uploaduj", "upload_concurrency": "Paralelno uploadovanje", "upload_errors": "Otpremanje je završeno sa {count, plural, one {# greškom} other {# grešaka}}, osvežite stranicu da biste videli nove datoteke za otpremanje (upload).", - "upload_progress": "Preostalo {remaining} – Obrađeno {processed}/{total}", + "upload_progress": "Preostalo {remaining, number} – Obrađeno {processed, number}/{total, number}", "upload_skipped_duplicates": "Preskočeno {count, plural, one {# dupla datoteka} other {# duplih datoteka}}", "upload_status_duplicates": "Duplikati", "upload_status_errors": "Greške", @@ -1275,6 +1369,8 @@ "version": "Verzija", "version_announcement_closing": "Tvoj prijatelj, Aleks", "version_announcement_message": "Zdravo prijatelju, postoji nova verzija aplikacije, molimo vas da odvojite vreme da posetite napomene o izdanju i uverite se u svoje docker-compose.yml, i .env podešavanje je ažurirano kako bi se sprečile bilo kakve pogrešne konfiguracije, posebno ako koristite WatchTower ili bilo koji mehanizam koji automatski upravlja ažuriranjem vaše aplikacije.", + "version_history": "Istorija verzija", + "version_history_item": "Instalirano {version} {date}", "video": "Video zapis", "video_hover_setting": "Pusti sličicu videa kada lebdi", "video_hover_setting_description": "Pusti sličicu videa kada miš pređe preko stavke. Čak i kada je onemogućena, reprodukcija se može pokrenuti prelaskom miša preko ikone za reprodukciju.", @@ -1284,6 +1380,7 @@ "view_album": "Pogledaj album", "view_all": "Prikaži Sve", "view_all_users": "Prikaži sve korisnike", + "view_in_timeline": "Prikaži u vremenskoj liniji", "view_links": "Prikaži veze", "view_next_asset": "Pogledajte sledeću datoteku", "view_previous_asset": "Pogledaj prethodnu datoteku", diff --git a/i18n/sv.json b/i18n/sv.json new file mode 100644 index 0000000000..804ff50b2e --- /dev/null +++ b/i18n/sv.json @@ -0,0 +1,1111 @@ +{ + "about": "Om", + "account": "Konto", + "account_settings": "Kontoinställningar", + "acknowledge": "Bekräfta", + "action": "Åtgärd", + "actions": "Händelser", + "active": "Aktiva", + "activity": "Aktivitet", + "activity_changed": "Aktiviteten är {enabled, select, true {aktiverad} other {inaktiverad}}", + "add": "Lägg till", + "add_a_description": "Lägg till en beskrivning", + "add_a_location": "Lägg till en plats", + "add_a_name": "Lägg till ett namn", + "add_a_title": "Lägg till en titel", + "add_exclusion_pattern": "Lägg till uteslutningsmönster", + "add_import_path": "Lägg till importsökväg", + "add_location": "Lägg till plats", + "add_more_users": "Lägg till fler användare", + "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_album": "Lägg till i album", + "add_to_shared_album": "Lägg till i delat album", + "added_to_archive": "Tillagd i arkiv", + "added_to_favorites": "Tillagd till favoriter", + "added_to_favorites_count": "{count, number} tillagda till favoriter", + "admin": { + "add_exclusion_pattern_description": "Lägg till exkluderande mönster. Matchning med jokertecken *, ** samt ? är supporterat. För att ignorera alla filer i samtliga mappar som heter \"Raw\", använd \"**/Raw/**\". För att ignorera alla filer som slutar med \".tif\", använd \"**/*.tif\". För att ignorera en absolut sökväg, använd \"/sökväg/att/ignorera/**\".", + "asset_offline_description": "Denna externa bibliotekstillgång finns inte längre på disken och har flyttats till papperskorgen. Om filen flyttades inom biblioteket, kontrollera din tidslinje för den nya motsvarande tillgången. För att återställa denna tillgång, se till att filsökvägen nedan kan nås av Immich och skanna biblioteket.", + "authentication_settings": "Autentiseringsinställningar", + "authentication_settings_description": "Hantera lösenord, OAuth, och andra autentiseringsinställningar", + "authentication_settings_disable_all": "Är du säker på att du vill inaktivera alla inloggningsmetoder? Inloggning kommer att helt inaktiveras.", + "authentication_settings_reenable": "För att återaktivera, använd Server Command.", + "background_task_job": "Bakgrundsaktiviteter", + "check_all": "Välj alla", + "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?", + "confirm_delete_library_assets": "Är du säker på att du vill radera detta album? {count, plural, one {# objekt} other {Samtliga # objekt}} kommer att tas bort från Immich och åtgärden kan inte ångras. Filerna kommer att behållas på hårddisken.", + "confirm_email_below": "För att bekräfta, skriv ”{email}” nedan", + "confirm_reprocess_all_faces": "Är du säker på att du vill återprocessa alla ansikten? Detta kommer också rensa namngivna personer.", + "confirm_user_password_reset": "Är du säker på att du vill återställa {user}’s lösenord?", + "create_job": "Skapa jobb", + "crontab_guru": "Crontab-guru", + "disable_login": "Inaktivera inloggning", + "disabled": "Inaktiverad", + "duplicate_detection_job_description": "Kör maskininlärning på objekt för att upptäcka liknande bilder. Bygger på Smart Search", + "exclusion_pattern_description": "Exkluderingsmönster tillåter dig att ignorera filer och mappar när skanning görs av ditt album. Detta är användbart om du har mappar som innehåller filer som du inte vill importera, t.ex. RAW-filer.", + "external_library_created_at": "Externt bibliotek (skapat den {date})", + "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.", + "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", + "image_format": "Format", + "image_format_description": "WebP producerar mindre filer än JPEG, men kodas långsammare.", + "image_prefer_embedded_preview": "Föredra inbäddad förhandsgranskning", + "image_prefer_embedded_preview_setting_description": "Använd inbäddade förhandsvisningar i RAW-foton som indata till bildbehandling när det är tillgängligt. Detta kan ge mer exakta färger för vissa bilder, men kvaliteten på förhandsgranskningen är kameraberoende och bilden kan ha fler komprimeringsartefakter.", + "image_prefer_wide_gamut": "Föredrar brett spektrum", + "image_prefer_wide_gamut_setting_description": "Använd Display P3 för miniatyrer. Detta bevarar livfullheten bättre hos bilder med bred färgrymd, men bilder kan se annorlunda ut på gamla enheter med en gammal webbläsarversion. Med sRGB-bilder behålls i sitt format sRGB för att undvika färgskiftningar.", + "image_preview_description": "Mellanstor bild med avskalad metadata, används vid visning av en enskild tillgång och för maskininlärning", + "image_preview_format": "Förhandsgranskningsformat", + "image_preview_quality_description": "Förhandsgranska kvalitet från 1-100. Högre är bättre, men ger större filer och kan minska appens känslighet. Att ställa in ett lågt värde kan påverka kvaliteten på maskininlärning.", + "image_preview_resolution": "Förhandsgranska upplösning", + "image_preview_resolution_description": "Används vid visning av ett enstaka foto och för maskininlärning. Högre upplösningar kan bevara fler detaljer men tar längre tid att koda, har större filstorlekar och kan minska appens responsiva känsla.", + "image_preview_title": "Förhandsvisningsinställningar", + "image_quality": "Kvalitet", + "image_quality_description": "Bildkvalitet från 1-100. Högre är bättre för kvaliteten men ger större filer, det här alternativet påverkar förhandsgranskningen och miniatyrbilderna.", + "image_resolution": "Upplösning", + "image_resolution_description": "Högre upplösningar kan bevara fler detaljer men tar längre tid att koda, har större filstorlekar och kan minska appens känslighet.", + "image_settings": "Bildinställningar", + "image_settings_description": "Hantera kvalitet och upplösning på genererade bilder", + "image_thumbnail_description": "Liten miniatyrbild med avskalad metadata, används när du tittar på grupper av foton som huvudtidslinjen", + "image_thumbnail_format": "Miniatyrformat", + "image_thumbnail_quality_description": "Miniatyrkvalitet från 1-100. Högre är bättre, men ger större filer och kan minska appens känslighet.", + "image_thumbnail_resolution": "Miniatyrbildsupplösning", + "image_thumbnail_resolution_description": "Används när du tittar på grupper av foton (huvudtidslinje, albumvy, etc.). Högre upplösningar kan bevara fler detaljer men tar längre tid att koda, har större filstorlekar och kan minska appens responsiva känsla.", + "image_thumbnail_title": "Miniatyrbildsinställningar", + "job_concurrency": "{job} Samtidighet", + "job_created": "Jobb skapat", + "job_not_concurrency_safe": "Det här jobbet är inte samtidighetssäkert.", + "job_settings": "Jobbinställningar", + "job_settings_description": "Hantera samtidiga jobb", + "job_status": "Jobbstatus", + "jobs_delayed": "{jobCount, plural, other {# försenad}}", + "jobs_failed": "{jobCount, plural, other {# misslyckades}}", + "library_created": "Skapat bibliotek: {library}", + "library_cron_expression": "Cron-uttryck", + "library_cron_expression_description": "Ställ in intervallet för skanningen med cron-formatet. För mer information gå till t.ex. Crontab Guru ", + "library_cron_expression_presets": "Cron-uttrycksförinställningar", + "library_deleted": "Biblioteket har tagits bort", + "library_import_path_description": "Ange en mapp att importera. Den här mappen, inklusive undermappar, skannas efter bilder och videor.", + "library_scanning": "Periodisk skanning", + "library_scanning_description": "Konfigurera periodisk biblioteksskanning", + "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_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", + "logging_enable_description": "Aktivera loggning", + "logging_level_description": "När aktiverad, vilken loggnivå som ska användas.", + "logging_settings": "Loggning", + "machine_learning_clip_model": "CLIP modell", + "machine_learning_clip_model_description": "Namnet på en CLIP-modell listad här . Observera att du måste köra ett \"Smart Search\" jobb för alla bilder när du ändrar en modell.", + "machine_learning_duplicate_detection": "Dubblettdetektering", + "machine_learning_duplicate_detection_enabled": "Aktivera dubblett detektion", + "machine_learning_duplicate_detection_enabled_description": "Om den inaktiveras kommer exakt identiska tillgångar fortfarande att dedupliceras.", + "machine_learning_duplicate_detection_setting_description": "Använd CLIP-inbäddningar för att hitta troliga dubbletter", + "machine_learning_enabled": "Aktivera maskininlärning", + "machine_learning_enabled_description": "Om det är inaktiverat kommer alla ML-funktioner att inaktiveras oavsett inställningarna nedan.", + "machine_learning_facial_recognition": "Ansiktsigenkänning", + "machine_learning_facial_recognition_description": "Upptäck, känna igen och gruppera ansikten i bilder", + "machine_learning_facial_recognition_model": "Ansiktsigenkänningsmodell", + "machine_learning_facial_recognition_model_description": "Modeller är listade i fallande storleksordning. Större modeller är långsammare och använder mer minne, men ger bättre resultat. Observera att du måste köra Face Detection-jobbet för alla bilder när du ändrar en modell.", + "machine_learning_facial_recognition_setting": "Aktivera ansiktsigenkänning", + "machine_learning_facial_recognition_setting_description": "Om avmarkerad kommer bilder inte att kodas till ansiktsigenkänningen vilket innebär att bilder inte kommer att läggas till i listan av igenkända personer på sidan Utforska.", + "machine_learning_max_detection_distance": "Maximal detektions avstånd", + "machine_learning_max_detection_distance_description": "Maximalt avstånd mellan två bilder för att överväga dem dubbletter, från 0,001-0,1. Högre värden kommer att upptäcka fler dubbletter, men kan leda till falsk positivt.", + "machine_learning_max_recognition_distance": "Maximalt igenkänningsavstånd", + "machine_learning_max_recognition_distance_description": "Det maximala avståndet mellan två ansikten för att anses som samma person, från 0-2. Sänkning av denna kan medföra märkning av två personer som samma person, samtidigt som det kan förhindra att märkning av samma person som två olika personer. Observera att det är lättare att slå samman två personer än att dela en person i två, så ligg hellre närmare den lägre tröskel om det är möjligt.", + "machine_learning_min_detection_score": "Minsta detektions poäng", + "machine_learning_min_detection_score_description": "Lägsta självsäkerhetsnivå för att en sida ska upptäckas mellan 0-1. Lägre värden upptäcker fler sidor men kan resultera i falska positiv.", + "machine_learning_min_recognized_faces": "Minsta identifierade ansikten", + "machine_learning_min_recognized_faces_description": "Minsta antal identifierade ansikten för att en person ska kunna skapas. Om detta ökas blir ansiktsigenkänningen mer exakt, men risken för att ett ansikte inte kopplas till en person ökar.", + "machine_learning_settings": "Inställningar För Maskininlärning", + "machine_learning_settings_description": "Hantera funktioner och inställningar för maskininlärning", + "machine_learning_smart_search": "Smart Sökning", + "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", + "manage_concurrency": "Hantera samtidighet", + "manage_log_settings": "Hantera logginställningar", + "map_dark_style": "Mörk stil", + "map_enable_description": "Aktivera kartfunktioner", + "map_gps_settings": "Karta & GPS Inställningar", + "map_gps_settings_description": "Ändra kartor & GPS (Omvänd geokodning) inställningar", + "map_implications": "Kartfunktionen är beroende av en extern kartbitstjänst (tiles.immich.cloud)", + "map_light_style": "Ljus stil", + "map_manage_reverse_geocoding_settings": "Hantera inställningar för Omvänd geokodning", + "map_reverse_geocoding": "Omvänd Geokodning", + "map_reverse_geocoding_enable_description": "Aktivera omvänd geokodning", + "map_reverse_geocoding_settings": "Inställningar för omvänd geokodning", + "map_settings": "Karta", + "map_settings_description": "Hantera kartinställningar", + "map_style_description": "URL till en style.json-karto tema", + "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", + "metadata_faces_import_setting_description": "Importera ansikten från bildens EXIF-data och sidecar-fil", + "metadata_settings": "Metadata-inställningar", + "metadata_settings_description": "Hantera metadata-inställningar", + "migration_job": "Migrering", + "migration_job_description": "Migrera miniatyrbilder för resurser och ansikten till den senaste mappstrukturen", + "no_paths_added": "Inga vägar tillagda", + "no_pattern_added": "Inga mönster tillagda", + "note_apply_storage_label_previous_assets": "Obs: Om du vill använda lagringsetiketten på tidigare uppladdade tillgångar kör du", + "note_cannot_be_changed_later": "OBS: Detta kan inte ändras i efterhand!", + "note_unlimited_quota": "OBS: Skriv 0 för obegränsad kvota", + "notification_email_from_address": "Från adress", + "notification_email_from_address_description": "Avsändarens epost, t.ex.: \"Immich Fotoserver \"", + "notification_email_host_description": "Värd för epostservern (t.ex. smtp.immich.app)", + "notification_email_ignore_certificate_errors": "Ignorera certifikatfel", + "notification_email_ignore_certificate_errors_description": "Ignorera valideringsfel för TLS-certifikat (rekommenderas ej)", + "notification_email_password_description": "Lösenord att använda för att verifiera identitet med epostservern", + "notification_email_port_description": "Port på epostservern (t.ex. 25, 465 eller 587)", + "notification_email_sent_test_email_button": "Skicka test-epost och spara", + "notification_email_setting_description": "Inställningar för att skicka epostnotiser", + "notification_email_test_email": "Skicka test-epost", + "notification_email_test_email_failed": "Misslyckades med att skicka test-epost, undersök dina värden", + "notification_email_test_email_sent": "Ett testmail har skickats till {email}. Kontrollera din inkorg.", + "notification_email_username_description": "Användarnamn att använda vid autentisering med epost-servern", + "notification_enable_email_notifications": "Aktivera epost-notiser", + "notification_settings": "Notisinställningar", + "notification_settings_description": "Hantera notisinställingar, inklusive epost", + "oauth_auto_launch": "Autostart", + "oauth_auto_launch_description": "Starta OAuth-loginflödet automatiskt vid navigering till loginsidan", + "oauth_auto_register": "Autoregistrera", + "oauth_auto_register_description": "Registrera nya användare automatiskt efter inloggning med OAuth", + "oauth_button_text": "Knapptext", + "oauth_client_id": "Klient-ID", + "oauth_client_secret": "Klienthemlighet", + "oauth_enable_description": "Logga in med OAuth", + "oauth_issuer_url": "Utfärdar-URL", + "oauth_mobile_redirect_uri": "Telefonomdirigernings-URI", + "oauth_mobile_redirect_uri_override": "Telefonomdirigerings-URI överrskridning", + "oauth_mobile_redirect_uri_override_description": "Aktivera om OAuth-leverantören inte tillåter mobila URI:er, så som '{callback}'", + "oauth_profile_signing_algorithm": "Profilsigneringsalgorithm", + "oauth_profile_signing_algorithm_description": "Algorithm som används för att signera användarprofilen.", + "oauth_scope": "Omfattning", + "oauth_settings": "OAuth", + "oauth_settings_description": "Hantera OAuth-logininställningar", + "oauth_settings_more_details": "För ytterligare detaljer om denna funktion, se dokumentationen.", + "oauth_signing_algorithm": "Signeringsalgoritm", + "oauth_storage_label_claim": "Användaranknuten lagringsetikett", + "oauth_storage_label_claim_description": "Sätter automatiskt angiven användares lagringsetikett.", + "oauth_storage_quota_claim": "Användaranknuten lagringskvot", + "oauth_storage_quota_claim_description": "Sätter automatiskt angiven användares lagringskvot.", + "oauth_storage_quota_default": "Standardlagringskvot (GiB)", + "oauth_storage_quota_default_description": "Kvot i GiB som används när ingen fordran angetts (Ange 0 för obegränsad kvot).", + "offline_paths": "Filer som inte kan hittas", + "offline_paths_description": "Dessa resultat kan bero på manuell borttagning av filer som inte är en del av ett externt bibliotek.", + "password_enable_description": "Logga in med epost och lösenord", + "password_settings": "Lösenordsinloggning", + "password_settings_description": "Hantera inställningar för lösenords-inloggning", + "paths_validated_successfully": "Samtliga sökvägar kunde bekräftas", + "person_cleanup_job": "Person rensning", + "quota_size_gib": "Lagringskvot (GiB)", + "refreshing_all_libraries": "Samtliga bibliotek uppdateras", + "registration": "Administratörsregistrering", + "registration_description": "Du utses till administratör eftersom du är systemets första användare. Du ansvarar för administration och kan skapa ytterligare användare.", + "removing_deleted_files": "Tar bort offline-filer", + "repair_all": "Reparera alla", + "repair_matched_items": "Matchade {count, plural, one {# föremål} other {# föremål}}", + "repaired_items": "Reparerade {count, plural, one {# item} other {# items}}", + "require_password_change_on_login": "Kräv av användaren att byta lösenord vid första inloggning", + "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", + "scanning_library_for_changed_files": "Scannar bibliotek efter ändrade filer", + "scanning_library_for_new_files": "Skannar biblioteket efter nya filer", + "search_jobs": "Sök Jobb...", + "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)://", + "server_settings": "Serverinställningar", + "server_settings_description": "Hantera serverinställningar", + "server_welcome_message": "Välkomstmeddelande", + "server_welcome_message_description": "Ett meddelande som visas på inloggningssidan.", + "sidecar_job": "Medföljande metadata", + "sidecar_job_description": "Upptäck eller synkronisera medföljande metadata från filsystemet", + "slideshow_duration_description": "Antal sekunder att visa varje bild", + "smart_search_job_description": "Kör maskininlärning på objekt för att stödja smart sökning", + "storage_template_date_time_description": "Tidsstämpel för resursens skapande används för datum och tidsinformation", + "storage_template_date_time_sample": "Exempeltid {date}", + "storage_template_enable_description": "Aktivera mallmotor för lagring", + "storage_template_hash_verification_enabled": "Hash-verifiering aktiverat", + "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_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.", + "storage_template_path_length": "Uppskattad längdbegränsning på sökväg: {length, number}/{limit, number}", + "storage_template_settings": "Lagringsmall", + "storage_template_settings_description": "Hantera mappstruktur och filnamn för uppladdade resurser", + "storage_template_user_label": "{label} är användarens lagringsmärkning", + "system_settings": "Systeminställningar", + "tag_cleanup_job": "Markera för rensning", + "theme_custom_css_settings": "Anpassad CSS", + "theme_custom_css_settings_description": "Cascading Style Sheets möjliggör designanpassningar av Immich", + "theme_settings": "Temainställningar", + "theme_settings_description": "Hantera anpassningar av webbgränssnittet för Immich", + "these_files_matched_by_checksum": "Dessa filer matchas av deras kontrollsummor", + "thumbnail_generation_job": "Generera Miniatyrer", + "thumbnail_generation_job_description": "Generera stora, små och suddiga miniatyrer för varje objekt, samt för varje person", + "transcode_policy_description": "", + "transcoding_acceleration_api": "Accelerations-API", + "transcoding_acceleration_api_description": "API som kommer att interagera med din enhet för att accelerera omkodning. Inställning är 'best effort': vid fel kommer den att återgå till mjukvarubaserad omkodning. VP9 kan fungera eller inte, beroende på din hårdvara.", + "transcoding_acceleration_nvenc": "NVENC (kräver NVIDIA GPU)", + "transcoding_acceleration_qsv": "Quick Sync (kräver 7 generationens Intel CPU eller senare)", + "transcoding_acceleration_rkmpp": "RKMPP (bara med Rockchip SOCs)", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_accepted_audio_codecs": "Accepterade ljud-codecs", + "transcoding_accepted_audio_codecs_description": "Välj vilka ljud-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", + "transcoding_accepted_containers": "Accepterade behållare", + "transcoding_accepted_containers_description": "Välj vilka kontainerformat som inte behöver remuxas till MP4. Endast används för vissa transcoding-politischer.", + "transcoding_accepted_video_codecs": "Accepterade video-codecs", + "transcoding_accepted_video_codecs_description": "Välj vilka video-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", + "transcoding_advanced_options_description": "Val som de flesta användare inte bör behöva ändra", + "transcoding_audio_codec": "Ljud-codec", + "transcoding_audio_codec_description": "Opus är bästa kvalitetsvalet, men är inte lika kompatibelt med äldre enheter eller mjukvara.", + "transcoding_bitrate_description": "Videor som är i högre än max bithastighet eller inte i ett accepterat format", + "transcoding_codecs_learn_more": "För att läsa mer om terminologin här se FFmpeg-dokumentationen för H.264 kodek, HEVC kodek och VP9 kodek.", + "transcoding_constant_quality_mode": "Konstant kvalitetsläge", + "transcoding_constant_quality_mode_description": "ICQ är bättre än CQP, men vissa hårdvaruaccelerationsenheter stöder inte detta läge. Om det här alternativet är inställt föredras det angivna läget när kvalitetsbaserad kodning används. NVENC ignoreras eftersom det inte stöder ICQ.", + "transcoding_constant_rate_factor": "Konstant hastighetsfaktor (-crf)", + "transcoding_constant_rate_factor_description": "Nivå på videokvalitet. Typiska värden är 23 för H.264, 28 för HEVC, 31 för VP9 och 35 för AV1. Lägre är bättre, men producerar större filer.", + "transcoding_disabled_description": "Omkoda inte videofiler, detta kan störa uppspelning på vissa klienter", + "transcoding_hardware_acceleration": "Hardvaruacceleration", + "transcoding_hardware_acceleration_description": "Forskningsmässig; betydligt snabbare men med lägre kvalitet vid samma biträtta", + "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_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", + "transcoding_preferred_hardware_device": "Föredragen hårdvaruenhet", + "transcoding_preferred_hardware_device_description": "Gäller enbart VAAPI och QSV. Ställer in dri-läget som används för hårdvaruomkodning.", + "transcoding_preset_preset": "Förinställning (-preset)", + "transcoding_preset_preset_description": "Kompressionshastighet. Långsammare preset ger mindre filer och högre kvalitet för en given bitrate. VP9 ignorerar hastigheter högre än 'faster'.", + "transcoding_reference_frames": "Referensbildrutor", + "transcoding_reference_frames_description": "Antalet bildrutor som tas i beaktande när en given bildruta ska komprimeras. Högre värden ger effektivare kompression på bekostnad av långsammare kodning. 0 ställer in detta värde automatiskt.", + "transcoding_required_description": "Enbart videos som inte är ett accepterat format", + "transcoding_settings": "Inställningar för omkodning av video", + "transcoding_settings_description": "Hantera upplösningen och kodningen av videofiler", + "transcoding_target_resolution": "Förväntad upplösning", + "transcoding_target_resolution_description": "En högre upplösning kan bevara fler detaljer men kan ta längre tid at koda, ha större fil storlek och kan försämra appens svarstid.", + "transcoding_temporal_aq": "", + "transcoding_temporal_aq_description": "Gäller endast NVENC. Ökar kvaliteten på scener med hög detaljrikedom och låg rörelse. Kanske inte är kompatibel med äldre enheter.", + "transcoding_threads": "Trådar", + "transcoding_threads_description": "Högre värden leder till snabbare kodning, men lämnar mindre utrymme för servern att bearbeta andra uppgifter medan den är aktiv. Detta värde bör inte vara mer än antalet CPU-kärnor. Maximerar användningen om den är inställd på 0.", + "transcoding_tone_mapping": "", + "transcoding_tone_mapping_description": "Försöker att bevara utseendet på HDR-videor när de konverteras till SDR. Varje algoritm gör olika avvägningar för färg, detaljer och ljusstyrka. Hable bevarar detaljer, Mobius bevarar färg och Reinhard bevarar ljusstyrkan.", + "transcoding_tone_mapping_npl": "", + "transcoding_tone_mapping_npl_description": "Färgerna kommer att justeras för att se normala ut för en visning av denna ljusstyrka. Kontraintuitivt ökar lägre värden videons ljusstyrka och vice versa eftersom det kompenserar för skärmens ljusstyrka. 0 ställer in detta värde automatiskt.", + "transcoding_transcode_policy": "Omkodningspolicy", + "transcoding_transcode_policy_description": "Policy för när en video ska omkodas. HDR-videor kommer alltid att omkodas (förutom om omkodning är inaktiverad).", + "transcoding_two_pass_encoding": "Två-pass kodning", + "transcoding_two_pass_encoding_setting_description": "Koda om i två omgångar för att producera bättre kodade videor. När max bitrate är aktiverat (krävs för att det ska fungera med H.264 och HEVC), använder det här läget ett bithastighetsområde baserat på max bitrate och ignorerar CRF. För VP9 kan CRF användas om max bitrate är inaktiverat.", + "transcoding_video_codec": "Video Codec", + "transcoding_video_codec_description": "VP9 har hög effektivitet och webbkompatibilitet, men tar längre tid att omkoda. HEVC fungerar på liknande sätt, men har lägre webbkompatibilitet. H.264 är allmänt kompatibel och snabb att omkoda, men producerar mycket större filer. AV1 är den mest effektiva codec men saknar stöd på äldre enheter.", + "trash_enabled_description": "Aktivera papperskorgen", + "trash_number_of_days": "Antal dagar", + "trash_number_of_days_description": "Antal dagar för att förvara tillgångarna i papperskorgen innan de permanent tas bort", + "trash_settings": "Papperskorginställningar", + "trash_settings_description": "Hantera papperskorginställningar", + "untracked_files": "Ospårade filer", + "untracked_files_description": "Dessa filer spåras inte av applikationen. De kan vara resultatet av misslyckade rörelser, avbrutna uppladdningar eller kvarlämnade på grund av en bugg", + "user_cleanup_job": "Användarrensning", + "user_delete_delay": "{user}s konto och tillgångar kommer att schemaläggas för permanent radering om {delay, plural, one {# day} other {# days}}.", + "user_delete_delay_settings": "Borttagningsfördröjning", + "user_delete_delay_settings_description": "Antal dagar efter borttagning för att permanent radera en användares konto och tillgångar. Arbetet med borttagning av användare körs vid midnatt för att söka efter användare som är redo för radering. Ändringar av denna inställning kommer att utvärderas vid nästa körning.", + "user_delete_immediately": "{user} konto och tillgångar kommer att stå i kö för permanent radering.", + "user_delete_immediately_checkbox": "Köa användare och tillgångar för omedelbar radering", + "user_management": "Användarhantering", + "user_password_has_been_reset": "Användarens lösenord har återställts:", + "user_password_reset_description": "Ange det tillfälliga lösenordet till användaren och informera dem om att de kommer att behöva ändra lösenordet vid nästa inloggning.", + "user_restore_description": "{user} konto kommer att återställas.", + "user_restore_scheduled_removal": "Återställ användare - schemalagd borttagning {date, date, long}", + "user_settings": "Användarinställningar", + "user_settings_description": "Hantera användarinställningar", + "user_successfully_removed": "Användaren {email} har tagits bort.", + "version_check_enabled_description": "Aktivera versionskontroll", + "version_check_implications": "Funktionen för versionskontroll är beroende av periodisk kommunikation med github.com", + "version_check_settings": "Versionskontroll", + "version_check_settings_description": "Aktivera/inaktivera meddelandet om ny versionen", + "video_conversion_job": "Omkoda videor", + "video_conversion_job_description": "Koda om videor för bredare kompatibilitet med webbläsare och enheter" + }, + "admin_email": "Admin Email", + "admin_password": "Admin Lösenord", + "administration": "Administration", + "advanced": "Avancerat", + "age_months": "Ålder {months, plural, one {# month} other {# months}}", + "age_year_months": "Ålder 1 år, {months, plural, one {# month} other {# months}}", + "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", + "album_delete_confirmation": "Är du säker på att du vill ta bort albumet {album}?", + "album_delete_confirmation_description": "Om det här albumet delas kommer andra användare inte att kunna komma åt det längre.", + "album_info_updated": "Albuminformation uppdaterad", + "album_leave": "Lämna albumet?", + "album_leave_confirmation": "Är du säker på att du vill lämna {album}?", + "album_name": "Albumnamn", + "album_options": "Albumalternativ", + "album_remove_user": "Ta bort användare?", + "album_remove_user_confirmation": "Är du säker på att du vill ta bort {user}?", + "album_share_no_users": "Det verkar som att du har delat det här albumet med alla användare eller så har du inte någon användare att dela med.", + "album_updated": "Albumet uppdaterat", + "album_updated_setting_description": "Få ett e-postmeddelande när ett delat album har nya tillgångar", + "album_user_left": "Lämnade {album}", + "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", + "all": "Allt", + "all_albums": "Alla album", + "all_people": "Alla personer", + "all_videos": "Alla videor", + "allow_dark_mode": "Tillåt mörkt läge", + "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", + "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.", + "api_key_empty": "Ditt API-nyckelnamn ska inte vara tomt", + "api_keys": "API-Nycklar", + "app_settings": "Appinställningar", + "appears_in": "Visas i", + "archive": "Arkiv", + "archive_or_unarchive_photo": "Arkivera eller oarkivera fotot", + "archive_size": "Arkivstorlek", + "archive_size_description": "Konfigurera arkivstorleken för nedladdningar (i GiB)", + "archived": "", + "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_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_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...", + "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_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}}", + "assets_restore_confirmation": "Är du säker på att du vill återställa alla dina papperskorgen? Du kan inte ångra den här åtgärden! Observera att offlineobjekt inte kan återställas på detta sätt.", + "assets_restored_count": "Återställd {count, plural, one {# asset} other {# assets}}", + "assets_trashed_count": "Till Papperskorgen {count, plural, one {# asset} other {# assets}}", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Asset were}} är redan en del av albumet", + "authorized_devices": "Auktoriserade enheter", + "back": "Bakåt", + "back_close_deselect": "Tillbaka, stäng eller avmarkera", + "backward": "Bakåt", + "birthdate_saved": "Födelsedatumet har sparats", + "birthdate_set_description": "Födelsedatum används för att beräkna åldern på denna person vid tidpunkten för ett foto.", + "blurred_background": "Suddig bakgrund", + "bugs_and_feature_requests": "Buggar och funktionsförfrågningar", + "build": "Bygge", + "build_image": "Byggfil", + "bulk_delete_duplicates_confirmation": "Är du säker på att du vill massradera {count, plural, one {# duplicate asset} other {# duplicate assets}}? Detta kommer att behålla den största tillgången i varje grupp och permanent radera alla andra dubbletter. Du kan inte ångra den här åtgärden!", + "bulk_keep_duplicates_confirmation": "Är du säker på att du vill behålla {count, plural, one {# duplicate asset} other {# duplicate assets}}? Detta kommer att lösa alla dubbletter av grupper utan att ta bort någonting.", + "bulk_trash_duplicates_confirmation": "Är du säker på att du vill skicka till papperskorgen {count, plural, one {# duplicate asset} other {# duplicate assets}}? Detta kommer att behålla den största tillgången i varje grupp och alla andra dubbletter kasseras.", + "buy": "Köp Immich", + "camera": "Kamera", + "camera_brand": "Kameramärke", + "camera_model": "Kameramodell", + "cancel": "Avbryt", + "cancel_search": "Avbryt sökning", + "cannot_merge_people": "Kan inte slå samman personer", + "cannot_undo_this_action": "Du kan inte ångra den här åtgärden!", + "cannot_update_the_description": "Det går inte att uppdatera beskrivningen", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "Ändra datum", + "change_expiration_time": "Ändra utgångstid", + "change_location": "Ändra plats", + "change_name": "Byt namn", + "change_name_successfully": "Bytt namn framgångsrikt", + "change_password": "Ändra Lösenord", + "change_password_description": "Detta är antingen första gången du loggar in i systemet eller så har en begäran gjorts om att ändra ditt lösenord. Vänligen ange det nya lösenordet nedan.", + "change_your_password": "Ändra ditt lösenord", + "changed_visibility_successfully": "Synligheten har ändrats", + "check_all": "Markera alla", + "check_logs": "Kontrollera loggar", + "choose_matching_people_to_merge": "Välj matchande personer att slå samman", + "city": "Stad", + "clear": "Rensa", + "clear_all": "Rensa allt", + "clear_all_recent_searches": "Rensa alla senaste sökningar", + "clear_message": "Rensa meddelande", + "clear_value": "Rensa värde", + "clockwise": "Medsols", + "close": "Stäng", + "collapse": "Kollapsa", + "collapse_all": "Kollapsa alla", + "color": "Färg", + "color_theme": "Färgtema", + "comment_deleted": "Kommentar raderad", + "comment_options": "Kommentarsalternativ", + "comments_and_likes": "Kommentarer & likes", + "comments_are_disabled": "Kommentarer är avstängda", + "confirm": "Bekräfta", + "confirm_admin_password": "Bekräfta administratörslösenord", + "confirm_delete_shared_link": "Är du säker på att du vill ta bort den här delade länken?", + "confirm_password": "Bekräfta lösenord", + "contain": "Anpassa", + "context": "Sammanhang", + "continue": "Fortsätt", + "copied_image_to_clipboard": "Kopierade bilden till urklipp.", + "copied_to_clipboard": "Kopierat till urklipp!", + "copy_error": "Kopieringsfel", + "copy_file_path": "Kopiera filsökväg", + "copy_image": "Kopiera Bild", + "copy_link": "Kopiera länk", + "copy_link_to_clipboard": "Kopiera länken till urklipp", + "copy_password": "Kopiera lösenord", + "copy_to_clipboard": "Kopiera till Urklipp", + "country": "Land", + "cover": "Fyll", + "covers": "Omslag", + "create": "Skapa", + "create_album": "Skapa album", + "create_library": "Skapa Bibliotek", + "create_link": "Skapa länk", + "create_link_to_share": "Skapa länk att dela", + "create_link_to_share_description": "Låt alla med länken se de valda fotona", + "create_new_person": "Skapa ny person", + "create_new_person_hint": "Tilldela valda objekt till en ny person", + "create_new_user": "Skapa en ny användare", + "create_tag": "Skapa tagg", + "create_tag_description": "Skapa en ny tagg. För kapslade taggar anger du hela sökvägen för taggen inklusive snedstreck.", + "create_user": "Skapa användare", + "created": "Skapad", + "current_device": "Aktuell enhet", + "custom_locale": "Anpassad plats", + "custom_locale_description": "Formatera datum och siffror baserat på språket och regionen", + "dark": "Mörk", + "date_after": "Datum efter", + "date_and_time": "Datum och Tid", + "date_before": "Datum före", + "date_of_birth_saved": "Födelsedatumet har sparats", + "date_range": "Datumintervall", + "day": "Dag", + "deduplicate_all": "Deduplicera alla", + "default_locale": "Standardplats", + "default_locale_description": "Formatera datum och siffror baserat på din webbläsares lokalitet", + "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_key": "Ta bort nyckel", + "delete_library": "Ta bort bibliotek", + "delete_link": "Ta bort länk", + "delete_shared_link": "Ta bort delad länk", + "delete_tag": "Ta bort tagg", + "delete_tag_confirmation_prompt": "Är du säker på att du vill ta bort {tagName}-taggen?", + "delete_user": "Ta bort användare", + "deleted_shared_link": "Ta bort delad länk", + "deletes_missing_assets": "Tar bort objekt som saknas från disken", + "description": "Beskrivning", + "details": "Detaljer", + "direction": "Riktning", + "disabled": "Inaktiverad", + "disallow_edits": "Tillåt inte redigeringar", + "discover": "Upptäck", + "dismiss_all_errors": "Avvisa alla fel", + "dismiss_error": "Avvisa fel", + "display_options": "Visningsalternativ", + "display_order": "Visa Ordning", + "display_original_photos": "Visa originalfoton", + "display_original_photos_setting_description": "Föredrar att visa originalfotot när du visar en tillgång snarare än miniatyrbilder när den ursprungliga tillgången är webbkompatibel. Detta kan resultera i långsammare bildvisningshastigheter.", + "do_not_show_again": "Visa inte det här meddelandet igen", + "documentation": "Dokumentation", + "done": "Klart", + "download": "Ladda ner", + "download_include_embedded_motion_videos": "Inbäddade videor", + "download_include_embedded_motion_videos_description": "Inkludera videor inbäddade i rörliga bilder som en separat fil", + "download_settings": "Ladda ner", + "download_settings_description": "Hantera inställningar relaterade till nedladdning av objekt", + "downloading": "Laddar ner", + "downloading_asset_filename": "Laddar ned objekt {filename}", + "drop_files_to_upload": "Släpp filer var som helst för att ladda upp", + "duplicates": "Dubletter", + "duplicates_description": "Lös varje grupp genom att ange vilka, om några, är dubbletter", + "duration": "Varaktighet", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "Redigera", + "edit_album": "Redigera album", + "edit_avatar": "Redigera avatar", + "edit_date": "Redigera datum", + "edit_date_and_time": "Redigera datum och tid", + "edit_exclusion_pattern": "Redigera uteslutningsmönster", + "edit_faces": "Redigera ansikten", + "edit_import_path": "Redigera importsökvägar", + "edit_import_paths": "Redigera importsökvägar", + "edit_key": "Redigera nyckel", + "edit_link": "Redigera länk", + "edit_location": "Redigera plats", + "edit_name": "Redigera namn", + "edit_people": "Redigera personer", + "edit_tag": "Redigera tagg", + "edit_title": "Redigera titel", + "edit_user": "Redigera användare", + "edited": "Redigerad", + "editor": "Redigerare", + "editor_close_without_save_prompt": "Ändringarna kommer inte att sparas", + "editor_close_without_save_title": "Stäng redigeraren?", + "editor_crop_tool_h2_aspect_ratios": "Bildförhållande", + "editor_crop_tool_h2_rotation": "Rotation", + "email": "Epost", + "empty": "", + "empty_album": "", + "empty_trash": "Töm papperskorg", + "empty_trash_confirmation": "Är du säker på att du vill tömma papperskorgen? Detta tar bort alla objekt i papperskorgen permanent från Immich.\nDu kan inte ångra den här åtgärden!", + "enable": "Aktivera", + "enabled": "Aktiverad", + "end_date": "Slutdatum", + "error": "Fel", + "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_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", + "cant_get_number_of_comments": "Kan inte få antal kommentarer", + "cant_search_people": "Kan inte söka efter personer", + "cant_search_places": "Kan inte söka platser", + "cleared_jobs": "Raderade jobb för: {job}", + "error_adding_assets_to_album": "Det gick inte att lägga till objekt i albumet", + "error_adding_users_to_album": "Det gick inte att lägga till användare till albumet", + "error_deleting_shared_user": "Det gick inte att ta bort delad användare", + "error_downloading": "Fel vid nedladdning av {filename}", + "error_hiding_buy_button": "Det gick inte att dölja köpknappen", + "error_removing_assets_from_album": "Det gick inte att ta bort objekt från albumet, kontrollera konsolen för mer information", + "error_selecting_all_assets": "Fel vid val av alla objekt", + "exclusion_pattern_already_exists": "Detta uteslutningsmönster finns redan.", + "failed_job_command": "Kommandot {command} misslyckades för jobbet: {job}", + "failed_to_create_album": "Det gick inte att skapa album", + "failed_to_create_shared_link": "Det gick inte att skapa delad länk", + "failed_to_edit_shared_link": "Det gick inte att redigera delad länk", + "failed_to_get_people": "Det gick inte att hämta personer", + "failed_to_load_asset": "Det gick inte att ladda objekt", + "failed_to_load_assets": "Det gick inte att ladda objekten", + "failed_to_load_people": "Det gick inte att ladda personer", + "failed_to_remove_product_key": "Det gick inte att ta bort produktnyckeln", + "failed_to_stack_assets": "Det gick inte att stapla objekt", + "failed_to_unstack_assets": "Det gick inte att avstapla objekt", + "import_path_already_exists": "Denna importsökväg finns redan.", + "incorrect_email_or_password": "Felaktig e-postadress eller lösenord", + "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} misslyckades valideringen", + "profile_picture_transparent_pixels": "Profilbilder kan inte ha genomskinliga pixlar. Zooma in och/eller flytta bilden.", + "quota_higher_than_disk_size": "Du har angett en kvot som är högre än diskstorleken", + "repair_unable_to_check_items": "Det går inte att kontrollera {count, select, one {item} other {items}}", + "unable_to_add_album_users": "Kunde inte lägga till använder i album", + "unable_to_add_assets_to_shared_link": "Det går inte att lägga till objekt till delad länk", + "unable_to_add_comment": "Kunde inte lägga till kommentar", + "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_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", + "unable_to_change_date": "Kunde inte ändra datum", + "unable_to_change_favorite": "Det går inte att ändra favorit för objekt", + "unable_to_change_location": "Kunde inte ändra plats", + "unable_to_change_password": "Det går inte att ändra lösenord", + "unable_to_change_visibility": "Det gick inte att ändra synligheten för {count, plural, one {# person} other {# people}}", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "Det gick inte att slutföra OAuth-inloggning", + "unable_to_connect": "Det går inte att ansluta", + "unable_to_connect_to_server": "Det går inte att ansluta till servern", + "unable_to_copy_to_clipboard": "Kan inte kopiera till urklipp, se till att du kommer åt sidan via https", + "unable_to_create_admin_account": "Det gick inte att skapa ett administratörskonto", + "unable_to_create_api_key": "Det gick inte att skapa en ny API-nyckel", + "unable_to_create_library": "Kunde inte skapa bibliotek", + "unable_to_create_user": "Kunde inte skapa användare", + "unable_to_delete_album": "Kunde inte ta bort album", + "unable_to_delete_asset": "Det gick inte att ta bort objekt", + "unable_to_delete_assets": "Det gick inte att ta bort objekt", + "unable_to_delete_exclusion_pattern": "Det gick inte att ta bort uteslutningsmönster", + "unable_to_delete_import_path": "Det gick inte att ta bort importsökvägen", + "unable_to_delete_shared_link": "Det gick inte att ta bort delad länk", + "unable_to_delete_user": "Kunde inte ta bort användare", + "unable_to_download_files": "Det går inte att ladda ner filer", + "unable_to_edit_exclusion_pattern": "Det gick inte att redigera uteslutningsmönster", + "unable_to_edit_import_path": "Det gick inte att redigera importsökvägen", + "unable_to_empty_trash": "Kunde inte tömma papperskorgen", + "unable_to_enter_fullscreen": "Kunde inte växla till fullskärm", + "unable_to_exit_fullscreen": "Kunde inte avsluta fullskärm", + "unable_to_get_comments_number": "Det gick inte att hämta antalet kommentarer", + "unable_to_get_shared_link": "Det gick inte att hämta delad länk", + "unable_to_hide_person": "Det går inte att dölja personen", + "unable_to_link_motion_video": "Det går inte att länka rörlig video", + "unable_to_link_oauth_account": "Det gick inte att länka OAuth-kontot", + "unable_to_load_album": "Det gick inte att ladda albumet", + "unable_to_load_asset_activity": "", + "unable_to_load_items": "", + "unable_to_load_liked_status": "", + "unable_to_log_out_all_devices": "Det gick inte att logga ut alla enheter", + "unable_to_log_out_device": "Det gick inte att logga ut enheten", + "unable_to_login_with_oauth": "Det gick inte att logga in med OAuth", + "unable_to_play_video": "Kunde inte spela upp video", + "unable_to_refresh_user": "", + "unable_to_remove_album_users": "", + "unable_to_remove_api_key": "Det gick inte att ta bort API Keyet", + "unable_to_remove_comment": "", + "unable_to_remove_library": "Kunde inte ta bort bibliotek", + "unable_to_remove_partner": "Kunde inte ta bort partner", + "unable_to_remove_reaction": "Kunde inte ta bort reaktion", + "unable_to_remove_user": "", + "unable_to_repair_items": "", + "unable_to_reset_password": "Kunde inte återställa lösenord", + "unable_to_resolve_duplicate": "", + "unable_to_restore_assets": "", + "unable_to_restore_trash": "", + "unable_to_restore_user": "Kunde inte återställa användare", + "unable_to_save_album": "Kunde inte spara album", + "unable_to_save_name": "Kunde inte spara namn", + "unable_to_save_profile": "Kunde inte spara profil", + "unable_to_save_settings": "Kunde inte spara inställningar", + "unable_to_scan_libraries": "", + "unable_to_scan_library": "", + "unable_to_set_profile_picture": "", + "unable_to_submit_job": "", + "unable_to_trash_asset": "", + "unable_to_unlink_account": "", + "unable_to_update_library": "Kunde inte uppdatera bibliotek", + "unable_to_update_location": "Kunde inte uppdatera plats", + "unable_to_update_settings": "Kunde inte uppdatera inställningar", + "unable_to_update_user": "Kunde inte uppdatera användare" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "Exif", + "exit_slideshow": "Avsluta bildspel", + "expand_all": "Expandera alla", + "expire_after": "Går ut efter", + "expired": "Gått ut", + "expires_date": "Går ut {date}", + "explore": "Utforska", + "explorer": "Utforskare", + "export": "Exportera", + "export_as_json": "Exportera som JSON", + "extension": "", + "external_libraries": "Externa Bibliotek", + "failed_to_get_people": "", + "favorite": "Favorit", + "favorite_or_unfavorite_photo": "", + "favorites": "Favoriter", + "feature": "", + "feature_photo_updated": "", + "featurecollection": "", + "file_name": "Filnamn", + "file_name_or_extension": "Filnamn eller -tillägg", + "filename": "Filnamn", + "files": "", + "filetype": "Filtyp", + "filter_people": "Filtrera personer", + "fix_incorrect_match": "", + "force_re-scan_library_files": "", + "forward": "Framåt", + "general": "", + "get_help": "", + "getting_started": "", + "go_back": "Gå tillbaka", + "go_to_search": "Gå till sök", + "go_to_share_page": "", + "group_albums_by": "", + "has_quota": "", + "hide_gallery": "Dölj galleri", + "hide_password": "Dölj lösenord", + "hide_person": "Dölj person", + "host": "Värd", + "hour": "Timme", + "image": "Bild", + "img": "", + "immich_logo": "Immich Logo", + "import_from_json": "Importera från JSON", + "import_path": "Importsökväg", + "in_archive": "", + "include_archived": "Inkludera arkiverade", + "include_shared_albums": "Inkludera delade album", + "include_shared_partner_assets": "", + "individual_share": "", + "info": "", + "interval": { + "day_at_onepm": "", + "hours": "", + "night_at_midnight": "", + "night_at_twoam": "" + }, + "invite_people": "", + "invite_to_album": "Bjuder in till album", + "job_settings_description": "", + "jobs": "Jobb", + "keep": "", + "keyboard_shortcuts": "", + "language": "", + "language_setting_description": "", + "last_seen": "", + "leave": "", + "let_others_respond": "Låt andra svara", + "level": "", + "library": "Bibliotek", + "library_options": "", + "light": "", + "link_options": "", + "link_to_oauth": "", + "linked_oauth_account": "", + "list": "", + "loading": "", + "loading_search_results_failed": "", + "log_out": "Logga ut", + "log_out_all_devices": "", + "login_has_been_disabled": "", + "look": "", + "loop_videos": "", + "loop_videos_description": "Aktivera för att automatiskt loopa en video i detaljvisaren.", + "make": "Tillverkare", + "manage_shared_links": "Hantera Delade länkar", + "manage_sharing_with_partners": "", + "manage_the_app_settings": "", + "manage_your_account": "Hantera ditt konto", + "manage_your_api_keys": "", + "manage_your_devices": "", + "manage_your_oauth_connection": "", + "map": "Karta", + "map_marker_with_image": "", + "map_settings": "Kartinställningar", + "media_type": "Mediatyp", + "memories": "", + "memories_setting_description": "", + "menu": "", + "merge": "", + "merge_people": "", + "merge_people_successfully": "", + "minimize": "", + "minute": "", + "missing": "Saknade", + "model": "Modell", + "month": "Månad", + "more": "", + "moved_to_trash": "", + "my_albums": "", + "name": "Namn", + "name_or_nickname": "", + "never": "aldrig", + "new_api_key": "", + "new_password": "Nytt lösenord", + "new_person": "", + "new_user_created": "", + "newest_first": "", + "next": "Nästa", + "next_memory": "", + "no": "", + "no_albums_message": "", + "no_archived_assets_message": "", + "no_assets_message": "", + "no_exif_info_available": "", + "no_explore_results_message": "", + "no_favorites_message": "", + "no_libraries_message": "", + "no_name": "", + "no_places": "", + "no_results": "", + "no_shared_albums_message": "", + "not_in_any_album": "Inte i något album", + "notes": "", + "notification_toggle_setting_description": "", + "notifications": "Notifikationer", + "notifications_setting_description": "", + "oauth": "", + "offline": "", + "ok": "", + "oldest_first": "", + "online": "", + "only_favorites": "", + "only_refreshes_modified_files": "", + "open_the_search_filters": "", + "options": "Val", + "organize_your_library": "Organisera ditt bibliotek", + "other": "", + "other_devices": "", + "other_variables": "", + "owned": "Ägd", + "owner": "Ägare", + "partner_sharing": "", + "partners": "", + "password": "Lösenord", + "password_does_not_match": "", + "password_required": "", + "password_reset_success": "", + "past_durations": { + "days": "", + "hours": "", + "years": "" + }, + "path": "", + "pattern": "", + "pause": "", + "pause_memories": "", + "paused": "", + "pending": "", + "people": "Personer", + "people_sidebar_description": "", + "perform_library_tasks": "", + "permanent_deletion_warning": "", + "permanent_deletion_warning_setting_description": "", + "permanently_delete": "", + "permanently_deleted_asset": "", + "photos": "Foton", + "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Foton}}", + "photos_from_previous_years": "Foton från tidigare år", + "pick_a_location": "", + "place": "Plats", + "places": "Platser", + "play": "", + "play_memories": "", + "play_motion_photo": "", + "play_or_pause_video": "", + "point": "", + "port": "", + "preset": "", + "preview": "", + "previous": "", + "previous_memory": "", + "previous_or_next_photo": "", + "primary": "", + "profile_picture_set": "", + "public_share": "", + "range": "", + "raw": "", + "reaction_options": "", + "read_changelog": "", + "recent": "", + "recent_searches": "", + "refresh": "", + "refreshed": "", + "refreshes_every_file": "", + "remove": "", + "remove_deleted_assets": "", + "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": "", + "reset_settings_to_default": "", + "restore": "Återställ", + "restore_user": "", + "retry_upload": "", + "review_duplicates": "Granska dubbletter", + "role": "", + "save": "Spara", + "saved_profile": "", + "saved_settings": "", + "say_something": "Säg något", + "scan_all_libraries": "Skanna alla bibliotek", + "scan_all_library_files": "", + "scan_new_library_files": "", + "scan_settings": "", + "search": "Sök", + "search_albums": "", + "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_type": "Söktyp", + "search_your_photos": "Sök bland dina foton", + "searching_locales": "", + "second": "", + "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_photos": "Välj foton", + "selected": "", + "send_message": "", + "server": "Server", + "server_stats": "Serverstatistik", + "set": "", + "set_as_album_cover": "", + "set_as_profile_picture": "Ange som profilbild", + "set_date_of_birth": "", + "set_profile_picture": "", + "set_slideshow_to_fullscreen": "", + "settings": "Inställningar", + "settings_saved": "", + "share": "Dela", + "shared": "Delad", + "shared_by": "", + "shared_by_you": "", + "shared_links": "Delade Länkar", + "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": "", + "show_metadata": "Visa metadata", + "show_or_hide_info": "", + "show_password": "", + "show_person_options": "", + "show_progress_bar": "", + "show_search_options": "", + "shuffle": "", + "sign_out": "Logga ut", + "sign_up": "", + "size": "", + "skip_to_content": "", + "slideshow": "", + "slideshow_settings": "", + "sort_albums_by": "", + "stack": "Stapel", + "stack_selected_photos": "", + "stacktrace": "", + "start": "Starta", + "start_date": "Startdatum", + "state": "Stat", + "status": "Status", + "stop_motion_photo": "", + "stop_photo_sharing": "Sluta dela dina foton?", + "storage": "Lagring", + "storage_label": "", + "storage_usage": "{used} av {available} används", + "submit": "", + "suggestions": "Förslag", + "sunrise_on_the_beach": "Soluppgång på stranden", + "swap_merge_direction": "", + "sync": "Synka", + "template": "Mall", + "theme": "Tema", + "theme_selection": "Val av tema", + "theme_selection_description": "Ställ in temat automatiskt till ljust eller mörkt baserat på din webbläsares inställningar", + "time_based_memories": "Tidsbaserade minnen", + "timezone": "Tidszon", + "to_archive": "Arkivera", + "to_change_password": "Ändra lösenord", + "to_favorite": "Favorit", + "to_login": "Logga in", + "toggle_settings": "", + "toggle_theme": "Växla tema", + "toggle_visibility": "Växla synlighet", + "total_usage": "Total användning", + "trash": "Papperskorg", + "trash_all": "", + "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": "", + "unfavorite": "Avfavorisera", + "unhide_person": "", + "unknown": "Okänd", + "unknown_album": "Okänt album", + "unknown_year": "Okänt år", + "unlimited": "Obegränsat", + "unlink_oauth": "", + "unlinked_oauth_account": "", + "unsaved_change": "Osparade ändringar", + "unselect_all": "", + "unstack": "Stapla Av", + "up_next": "", + "updated_password": "Lösenordet har uppdaterats", + "upload": "Ladda upp", + "upload_concurrency": "", + "upload_status_duplicates": "Dubbletter", + "upload_status_errors": "Fel", + "url": "URL", + "usage": "Användning", + "user": "Användare", + "user_id": "Användar-ID", + "user_purchase_settings": "Köp", + "user_purchase_settings_description": "Hantera dina köp", + "user_usage_detail": "", + "username": "Användarnamn", + "users": "Användare", + "utilities": "Verktyg", + "validate": "Validera", + "variables": "Variabler", + "version": "Version", + "version_announcement_closing": "Din vän, Alex", + "video": "Video", + "video_hover_setting_description": "", + "videos": "Videor", + "videos_count": "{count, plural, one {# Video} other {# Videor}}", + "view": "Visa", + "view_album": "Visa Album", + "view_all": "Visa alla", + "view_all_users": "Visa alla användare", + "view_in_timeline": "Visa i tidslinjen", + "view_links": "Visa länkar", + "view_next_asset": "Visa nästa objekt", + "view_previous_asset": "Visa föregående objekt", + "viewer": "", + "waiting": "Väntar", + "warning": "Varning", + "week": "Vecka", + "welcome": "Välkommen", + "welcome_to_immich": "Välkommen till immich", + "year": "År", + "years_ago": "{years, plural, one {# år} other {# år}} sedan", + "yes": "Ja", + "you_dont_have_any_shared_links": "Du har inga delade länkar", + "zoom_image": "Zooma bild" +} diff --git a/web/src/lib/i18n/ta.json b/i18n/ta.json similarity index 97% rename from web/src/lib/i18n/ta.json rename to i18n/ta.json index 543bfda2cd..e4201806f7 100644 --- a/web/src/lib/i18n/ta.json +++ b/i18n/ta.json @@ -1,4 +1,5 @@ { + "about": "விபரம்", "account": "கணக்கு", "account_settings": "கணக்கு அமைவுகள்", "acknowledge": "ஒப்புக்கொள்கிறேன்", @@ -6,6 +7,7 @@ "actions": "செயல்கள்", "active": "செயல்பாட்டில்", "activity": "செயல்பாடுகள்", + "activity_changed": "செயல்பாடு {இயக்கப்பட்டது, தேர்ந்தெடு, சரி {இயக்கப்பட்டது} மற்றது {முடக்கப்பட்டது}}", "add": "சேர்", "add_a_description": "விவரம் சேர்", "add_a_location": "இடத்தை சேர்க்கவும்", @@ -25,11 +27,11 @@ "added_to_favorites": "விருப்பங்களில் (பேவரிட்ஸ்) சேர்க்கப்பட்டது", "added_to_favorites_count": "விருப்பங்களில் (பேவரிட்ஸ்) {count} சேர்க்கப்பட்டது", "admin": { - "add_exclusion_pattern_description": "", + "add_exclusion_pattern_description": "விலக்கு வடிவங்களைச் சேர்க்கவும். *, **, மற்றும் ? ஆதரிக்கப்படுகிறது. \"Raw\" என்ற பெயரிடப்பட்ட எந்த கோப்பகத்திலும் உள்ள எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/Raw/**\" ஐப் பயன்படுத்தவும். \".tif\" இல் முடியும் எல்லா கோப்புகளையும் புறக்கணிக்க, \"**/*.tif\" ஐப் பயன்படுத்தவும். ஒரு முழுமையான பாதையை புறக்கணிக்க, \"/path/to/ignore/**\" ஐப் பயன்படுத்தவும்.", "authentication_settings": "அடையாள உறுதிப்படுத்தல் அமைப்புகள் (செட்டிங்ஸ்)", "authentication_settings_description": "கடவுச்சொல், OAuth, மற்றும் பிற அடையாள அமைப்புகள்", "authentication_settings_disable_all": "எல்லா உள்நுழைவு முறைகளையும் நிச்சயமாக முடக்க விரும்புகிறீர்களா? உள்நுழைவு முற்றிலும் முடக்கப்படும்.", - "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்", + "authentication_settings_reenable": "மீண்டும் இயக்க, சர்வர் கட்டளை பயன்படுத்தவும்.", "background_task_job": "பின்னணி பணிகள்", "check_all": "அனைத்தையும் தேர்ந்தெடு", "cleared_jobs": "முடித்த வேலைகள்: {job}", @@ -39,6 +41,7 @@ "confirm_email_below": "உறுதிப்படுத்த, கீழே \"{email}\" என தட்டச்சு செய்யவும்", "confirm_reprocess_all_faces": "எல்லா முகங்களையும் மீண்டும் செயலாக்க விரும்புகிறீர்களா? இது பெயரிடப்பட்ட நபர்களையும் அழிக்கும்.", "confirm_user_password_reset": "{user} இன் கடவுச்சொல்லை நிச்சயமாக மீட்டமைக்க விரும்புகிறீர்களா?", + "create_job": "வேலையை உருவாக்கு", "disable_login": "உள்நுழைவை முடக்கு", "duplicate_detection_job_description": "ஒத்த படங்களைக் கண்டறிய, சொத்துக்களில் இயந்திரக் கற்றலை இயக்கவும். ஸ்மார்ட் தேடலை நம்பியுள்ளது", "exclusion_pattern_description": "உங்கள் நூலகத்தை ஸ்கேன் செய்யும் போது கோப்புகளையும் கோப்புறைகளையும் புறக்கணிக்க விலக்கு வடிவங்கள் உங்களை அனுமதிக்கின்றன. RAW கோப்புகள் போன்ற நீங்கள் இறக்குமதி செய்ய விரும்பாத கோப்புகளைக் கொண்ட கோப்புறைகள் உங்களிடம் இருந்தால் இது பயனுள்ளதாக இருக்கும்.", @@ -47,13 +50,13 @@ "face_detection": "முகம் கண்டறிதல்", "face_detection_description": "இயந்திர கற்றலைப் பயன்படுத்தி சொத்துக்களில் உள்ள முகங்களைக் கண்டறியவும். வீடியோக்களுக்கு, சிறுபடம் மட்டுமே கருதப்படுகிறது. \"அனைத்து\" (மறு-) அனைத்து சொத்துகளையும் செயலாக்குகிறது. இதுவரை செயலாக்கப்படாத புகைப்பட சொத்துக்களை \"காணவில்லை\" வரிசைப்படுத்துகிறது. முகம் கண்டறிதல் முடிந்ததும், கண்டறியப்பட்ட முகங்கள், ஏற்கனவே இருக்கும் அல்லது புதிய நபர்களாகக் குழுவாக்கப்பட்டு, முக அடையாளத்திற்காக வரிசையில் நிறுத்தப்படும்.", "facial_recognition_job_description": "நபர்களின் முகங்களைக் குழு கண்டறிந்தது. முகம் கண்டறிதல் முடிந்ததும் இந்தப் படி இயங்கும். அனைத்து முகங்களையும் \"அனைத்து\" (மறு-) கொத்துகள். \"காணவில்லை\" என்பது நபர் நியமிக்கப்படாத முகங்களை வரிசைப்படுத்துகிறது.", - "failed_job_command": "", + "failed_job_command": "பணிக்கான கட்டளை {command} தோல்வியடைந்தது: {job}", "force_delete_user_warning": "எச்சரிக்கை: இது பயனரையும் அனைத்து புகைப்பட சொத்துகளையும் உடனடியாக அகற்றும். இதை செயல்தவிர்க்க முடியாது மற்றும் புகைப்படங்களை மீட்டெடுக்க முடியாது.", "forcing_refresh_library_files": "அனைத்து லைப்ரரி புகைப்படங்களையும் கட்டாயப்படுத்தி புதுப்பிக்கவும்", "image_format_description": "WebP, JPEG ஐ விட சிறிய கோப்புகளை உருவாக்குகிறது, ஆனால் குறியாக்கம் செய்ய மெதுவாக உள்ளது.", "image_prefer_embedded_preview": "உட்பொதிந்த படத்தை முன்னிடு", "image_prefer_embedded_preview_setting_description": "", - "image_prefer_wide_gamut": "", + "image_prefer_wide_gamut": "அகன்ற வண்ணவரம்பு தேர்வு", "image_prefer_wide_gamut_setting_description": "", "image_preview_format": "", "image_preview_resolution": "", @@ -141,7 +144,7 @@ "note_cannot_be_changed_later": "குறிப்பு: இதை பின்னர் மாற்ற முடியாது!", "note_unlimited_quota": "குறிப்பு: வரம்பற்ற ஒதுக்கீட்டிற்கு 0 ஐ உள்ளிடவும்", "notification_email_from_address": "முகவரியிலிருந்து", - "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", + "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", "notification_email_host_description": "மின்னஞ்சல் சேவையகத்தின் ஹோஸ்ட் (எடுத்துக்காட்டாக: smtp.immich.app)", "notification_email_ignore_certificate_errors": "சான்றிதழ் பிழைகளை புறக்கணிக்கவும்", "notification_email_ignore_certificate_errors_description": "TLS சான்றிதழ் சரிபார்ப்பு பிழைகளை புறக்கணிக்கவும் (பரிந்துரைக்கப்படவில்லை)", @@ -189,7 +192,7 @@ "refreshing_all_libraries": "அனைத்து நூலகங்களையும் புதுப்பிக்கிறது", "registration": "நிர்வாக பதிவு", "registration_description": "நீங்கள் கணினியில் முதல் பயனராக இருப்பதால், நீங்கள் நிர்வாகியாக நியமிக்கப்படுவீர்கள் மற்றும் நிர்வாகப் பணிகளுக்குப் பொறுப்பாவீர்கள், மேலும் உங்களால் கூடுதல் பயனர்கள் உருவாக்கப்படுவார்கள்.", - "removing_offline_files": "ஆஃப்லைன் கோப்புகளை நீக்குகிறது", + "removing_deleted_files": "ஆஃப்லைன் கோப்புகளை நீக்குகிறது", "repair_all": "அனைத்தையும் பழுதுபார்க்கவும்", "repair_matched_items": "பொருந்தியது {count, plural, one {# உருப்படி} other {# உருப்படிகள்}}", "repaired_items": "பழுதுபார்க்கப்பட்டது {count, plural, one {# உருப்படி} other {# உருப்படிகள்}}", @@ -514,8 +517,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "", @@ -756,10 +759,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "rename": "", "repair": "", diff --git a/i18n/te.json b/i18n/te.json new file mode 100644 index 0000000000..dc92a56d57 --- /dev/null +++ b/i18n/te.json @@ -0,0 +1,269 @@ +{ + "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": "భాగస్వామ్య ఆల్బమ్‌కు జోడించండి", + "added_to_archive": "ఆర్కైవ్‌కి జోడించబడింది", + "added_to_favorites": "ఇష్టమైన వాటికి జోడించబడింది", + "added_to_favorites_count": "ఇష్టమైన వాటికి {count, number} జోడించబడింది", + "admin": { + "add_exclusion_pattern_description": "మినహాయింపు నమూనాలను జోడించండి. *, ** మరియు ?ని ఉపయోగించి గ్లోబింగ్‌కు మద్దతు ఉంది. \"Raw\" అనే పేరు గల ఏదైనా డైరెక్టరీలోని అన్ని ఫైల్‌లను విస్మరించడానికి, \"**/Raw/**\"ని ఉపయోగించండి. \".tif\"తో ముగిసే అన్ని ఫైల్‌లను విస్మరించడానికి, \"**/*.tif\"ని ఉపయోగించండి. సంపూర్ణ మార్గాన్ని విస్మరించడానికి, \"/path/to/ignore/**\"ని ఉపయోగించండి.", + "authentication_settings": "ప్రమాణీకరణ సెట్టింగ్‌లు", + "authentication_settings_description": "పాస్‌వర్డ్, OAuth మరియు ఇతర ప్రమాణీకరణ సెట్టింగ్‌లను నిర్వహించండి", + "authentication_settings_disable_all": "మీరు ఖచ్చితంగా అన్ని లాగిన్ పద్ధతులను నిలిపివేయాలనుకుంటున్నారా? లాగిన్ పూర్తిగా నిలిపివేయబడుతుంది.", + "authentication_settings_reenable": "మళ్లీ ప్రారంబించటానికి, Server Commandని ఉపయోగించండి.", + "background_task_job": "నేపథ్య పనులు", + "check_all": "అన్నీ తనిఖీ చేయండి", + "cleared_jobs": "దీని కోసం ఉద్యోగాలు క్లియర్ చేయబడ్డాయి: {job}", + "config_set_by_file": "కాన్ఫిగరేషన్ ప్రస్తుతం కాన్ఫిగరేషన్ ఫైల్ ద్వారా సెట్ చేయబడింది", + "confirm_delete_library": "మీరు ఖచ్చితంగా {library} లైబ్రరీని తొలగించాలనుకుంటున్నారా?", + "confirm_delete_library_assets": "మీరు ఖచ్చితంగా ఈ లైబ్రరీని తొలగించాలనుకుంటున్నారా? ఇది Immich నుండి {count, plural, one {# కలిగి ఉన్న ఆస్తి} other {all # కలిగి ఉన్న ఆస్తులు}} తొలగిస్తుంది మరియు రద్దు చేయబడదు. ఫైల్‌లు డిస్క్‌లో ఉంటాయి.", + "confirm_email_below": "నిర్ధారించడానికి, క్రింద \"{email}\" టైప్ చేయండి", + "confirm_reprocess_all_faces": "మీరు ఖచ్చితంగా అన్ని ముఖాలను రీప్రాసెస్ చేయాలనుకుంటున్నారా? ఇది పేరున్న వ్యక్తులను కూడా క్లియర్ చేస్తుంది.", + "confirm_user_password_reset": "మీరు ఖచ్చితంగా {user} పాస్‌వర్డ్‌ని రీసెట్ చేయాలనుకుంటున్నారా?", + "disable_login": "లాగిన్‌ను నిలిపివేయండి", + "duplicate_detection_job_description": "సారూప్య చిత్రాలను గుర్తించడానికి ఆస్తులపై యంత్ర అభ్యాసాన్ని అమలు చేయండి. స్మార్ట్ శోధనపై ఆధారపడుతుంది", + "exclusion_pattern_description": "మినహాయింపు నమూనాలు మీ లైబ్రరీని స్కాన్ చేస్తున్నప్పుడు ఫైల్‌లు మరియు ఫోల్డర్‌లను విస్మరించడానికి మిమ్మల్ని అనుమతిస్తాయి. మీరు దిగుమతి చేయకూడదనుకునే RAW ఫైల్‌లు వంటి ఫోల్డర్‌లను కలిగి ఉన్నట్లయితే ఇది ఉపయోగకరంగా ఉంటుంది.", + "external_library_created_at": "బాహ్య లైబ్రరీ ({date}న సృష్టించబడింది)", + "external_library_management": "బాహ్య లైబ్రరీ నిర్వహణ", + "face_detection": "ముఖ గుర్తింపు", + "face_detection_description": "మెషిన్ లెర్నింగ్ ఉపయోగించి ఆస్తులలో ముఖాలను గుర్తించండి. వీడియోల కోసం, సూక్ష్మచిత్రం మాత్రమే పరిగణించబడుతుంది. \"అన్నీ\" (పునః) అన్ని ఆస్తులను ప్రాసెస్ చేస్తుంది. ఇంకా ప్రాసెస్ చేయని ఆస్తులను \"మిస్సింగ్\" క్యూలు చేస్తుంది. గుర్తించబడిన ముఖాలు ఇప్పటికే ఉన్న లేదా కొత్త వ్యక్తులతో సమూహపరచడం పూర్తయిన తర్వాత ముఖ గుర్తింపు కోసం క్యూలో ఉంచబడతాయి.", + "facial_recognition_job_description": "సమూహం వ్యక్తుల ముఖాలను గుర్తించింది. ఫేస్ డిటెక్షన్ పూర్తయిన తర్వాత ఈ దశ అమలవుతుంది. \"అన్ని\" (పునః) అన్ని ముఖాలను క్లస్టర్‌లు చేస్తుంది. \"తప్పిపోయిన\" వ్యక్తిని కేటాయించని ముఖాలను క్యూలో ఉంచుతుంది.", + "failed_job_command": "ఉద్యోగం కోసం కమాండ్ {command} విఫలమైంది: {job}", + "force_delete_user_warning": "హెచ్చరిక: ఇది వినియోగదారుని మరియు అన్ని ఆస్తులను వెంటనే తీసివేస్తుంది. ఇది రద్దు చేయబడదు మరియు ఫైల్‌లను తిరిగి పొందడం సాధ్యం కాదు.", + "forcing_refresh_library_files": "అన్ని లైబ్రరీ ఫైల్‌లను రిఫ్రెష్ చేయమని బలవంతం చేస్తోంది", + "image_format_description": "WebP JPEG కంటే చిన్న ఫైల్‌లను ఉత్పత్తి చేస్తుంది, కానీ ఎన్‌కోడ్ చేయడం నెమ్మదిగా ఉంటుంది.", + "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_preview_format": "ప్రివ్యూ ఫార్మాట్", + "image_preview_resolution": "ప్రివ్యూ రిజల్యూషన్", + "image_preview_resolution_description": "ఒకే ఫోటోను చూసేటప్పుడు మరియు మెషిన్ లెర్నింగ్ కోసం ఉపయోగించబడుతుంది. అధిక రిజల్యూషన్‌లు మరింత వివరాలను భద్రపరుస్తాయి కానీ ఎన్‌కోడ్ చేయడానికి ఎక్కువ సమయం పడుతుంది, పెద్ద ఫైల్ పరిమాణాలను కలిగి ఉంటాయి మరియు యాప్ ప్రతిస్పందనను తగ్గించవచ్చు.", + "image_quality": "నాణ్యత", + "image_quality_description": "1-100 నుండి చిత్ర నాణ్యత. నాణ్యత కోసం అధికమైనది ఉత్తమం కానీ పెద్ద ఫైల్‌లను ఉత్పత్తి చేస్తుంది, ఈ ఎంపిక ప్రివ్యూ మరియు థంబ్‌నెయిల్ చిత్రాలను ప్రభావితం చేస్తుంది.", + "image_settings": "చిత్రం సెట్టింగ్‌లు", + "image_settings_description": "రూపొందించబడిన చిత్రాల నాణ్యత మరియు రిజల్యూషన్‌ను నిర్వహించండి", + "image_thumbnail_format": "థంబ్‌నెయిల్ ఫార్మాట్", + "image_thumbnail_resolution": "థంబ్‌నెయిల్ రిజల్యూషన్", + "image_thumbnail_resolution_description": "ఫోటోల సమూహాలను వీక్షిస్తున్నప్పుడు ఉపయోగించబడుతుంది (ప్రధాన టైమ్‌లైన్, ఆల్బమ్ వీక్షణ మొదలైనవి). అధిక రిజల్యూషన్‌లు మరింత వివరాలను భద్రపరుస్తాయి కానీ ఎన్‌కోడ్ చేయడానికి ఎక్కువ సమయం పడుతుంది, పెద్ద ఫైల్ పరిమాణాలను కలిగి ఉంటాయి మరియు యాప్ ప్రతిస్పందనను తగ్గించవచ్చు.", + "job_concurrency": "{job} సమ్మతి", + "job_not_concurrency_safe": "ఈ ఉద్యోగం సమ్మతి-సురక్షితమైనది కాదు.", + "job_settings": "ఉద్యోగ సెట్టింగ్‌లు", + "job_settings_description": "ఉద్యోగ సమ్మతిని నిర్వహించండి", + "job_status": "ఉద్యోగ స్థితి", + "jobs_delayed": "{jobCount, plural, other {# ఆలస్యమైంది}}", + "jobs_failed": "{jobCount, plural, other {# విఫలమైంది}}", + "library_created": "లైబ్రరీ సృష్టించబడింది: {library}", + "library_cron_expression": "క్రాన్ వ్యక్తీకరణ", + "library_cron_expression_description": "క్రాన్ ఆకృతిని ఉపయోగించి స్కానింగ్ విరామాన్ని సెట్ చేయండి. మరింత సమాచారం కోసం దయచేసి చూడండి ఉదా. Crontab Guru", + "library_cron_expression_presets": "క్రాన్ వ్యక్తీకరణ ప్రీసెట్లు", + "library_deleted": "లైబ్రరీ తొలగించబడింది", + "library_import_path_description": "దిగుమతి చేయడానికి ఫోల్డర్‌ను పేర్కొనండి. సబ్ ఫోల్డర్‌లతో సహా ఈ ఫోల్డర్ చిత్రాలు మరియు వీడియోల కోసం స్కాన్ చేయబడుతుంది.", + "library_scanning": "ఆవర్తన స్కానింగ్", + "library_scanning_description": "ఆవర్తన లైబ్రరీ స్కానింగ్‌ని కాన్ఫిగర్ చేయండి", + "library_scanning_enable_description": "ఆవర్తన లైబ్రరీ స్కానింగ్‌ని ప్రారంభించండి", + "library_settings": "బాహ్య లైబ్రరీ", + "library_settings_description": "బాహ్య లైబ్రరీ సెట్టింగ్‌లను నిర్వహించండి", + "library_tasks_description": "లైబ్రరీ పనులను నిర్వహించండి", + "library_watching_enable_description": "ఫైల్ మార్పుల కోసం బాహ్య లైబ్రరీలను చూడండి", + "library_watching_settings": "లైబ్రరీ చూడటం (ప్రయోగాత్మకం)", + "library_watching_settings_description": "మారిన ఫైల్‌ల కోసం ఆటోమేటిక్‌గా చూడండి", + "logging_enable_description": "లాగింగ్‌ని ప్రారంభించండి", + "logging_level_description": "ప్రారంభించబడినప్పుడు, ఏ లాగ్ స్థాయిని ఉపయోగించాలి.", + "logging_settings": "లాగింగ్", + "machine_learning_clip_model": "CLIP మోడల్", + "machine_learning_clip_model_description": "ఇక్కడ జాబితా చేయబడిన CLIP మోడల్ పేరు. మీరు మోడల్‌ను మార్చిన తర్వాత అన్ని చిత్రాల కోసం 'స్మార్ట్ సెర్చ్' జాబ్‌ని మళ్లీ అమలు చేయాలని గుర్తుంచుకోండి.", + "machine_learning_duplicate_detection": "డూప్లికేట్ డిటెక్షన్", + "machine_learning_duplicate_detection_enabled": "నకిలీ గుర్తింపును ప్రారంభించండి", + "machine_learning_duplicate_detection_enabled_description": "నిలిపివేసినట్లయితే, సరిగ్గా ఒకేలాంటి ఆస్తులు ఇప్పటికీ డీ-డూప్లికేట్ చేయబడతాయి.", + "machine_learning_duplicate_detection_setting_description": "సంభావ్య నకిలీలను కనుగొనడానికి CLIP ఎంబెడ్డింగ్‌లను ఉపయోగించండి", + "machine_learning_enabled": "మెషిన్ లెర్నింగ్ ప్రారంభించండి", + "machine_learning_enabled_description": "డిజేబుల్ చేయబడితే, దిగువ సెట్టింగ్‌లతో సంబంధం లేకుండా అన్ని ML ఫీచర్‌లు నిలిపివేయబడతాయి.", + "machine_learning_facial_recognition": "ముఖ గుర్తింపు", + "machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను గుర్తించండి, గుర్తించండి మరియు సమూహపరచండి", + "machine_learning_facial_recognition_model": "ముఖ గుర్తింపు మోడల్", + "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_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_smart_search": "స్మార్ట్ శోధన", + "machine_learning_smart_search_description": "CLIP ఎంబెడ్డింగ్‌లను ఉపయోగించి అర్థపరంగా చిత్రాల కోసం శోధించండి", + "machine_learning_smart_search_enabled": "స్మార్ట్ శోధనను ప్రారంభించండి", + "machine_learning_smart_search_enabled_description": "నిలిపివేయబడితే, స్మార్ట్ శోధన కోసం చిత్రాలు ఎన్‌కోడ్ చేయబడవు.", + "machine_learning_url_description": "మెషిన్ లెర్నింగ్ సర్వర్ యొక్క URL", + "manage_concurrency": "కరెన్సీని నిర్వహించండి", + "manage_log_settings": "లాగ్ సెట్టింగ్‌లను నిర్వహించండి", + "map_dark_style": "చీకటి శైలి", + "map_enable_description": "మ్యాప్ లక్షణాలను ప్రారంభించండి", + "map_gps_settings": "మ్యాప్ & GPS సెట్టింగ్‌లు", + "map_gps_settings_description": "మ్యాప్ & GPS (రివర్స్ జియోకోడింగ్) సెట్టింగ్‌లను నిర్వహించండి", + "map_light_style": "పగటి శైలి", + "map_manage_reverse_geocoding_settings": "రివర్స్ జియోకోడింగ్ సెట్టింగ్‌లను నిర్వహించండి", + "map_reverse_geocoding": "రివర్స్ జియోకోడింగ్", + "map_reverse_geocoding_enable_description": "రివర్స్ జియోకోడింగ్‌ని ప్రారంభించండి", + "map_reverse_geocoding_settings": "రివర్స్ జియోకోడింగ్ సెట్టింగ్‌లు", + "map_settings": "మ్యాప్ సెట్టింగ్‌లు" + }, + "invite_to_album": "ఆల్బమ్‌కు ఆహ్వానించండి", + "jobs": "ఉద్యోగాలు", + "keep": "ఉంచండి", + "keep_all": "అన్ని ఉంచు", + "keyboard_shortcuts": "కీబోర్డ్ సత్వరమార్గాలు", + "language": "భాష", + "language_setting_description": "మీకు ఇష్టమైన భాషను ఎంచుకోండి", + "last_seen": "ఆఖరి సారిగా చూచింది", + "latitude": "అక్షాంశం", + "leave": "వదిలేయ్", + "let_others_respond": "ఇతరులు ప్రతిస్పందించనివ్వండి", + "level": "స్థాయి", + "library": "గ్రంధాలయం", + "library_options": "లైబ్రరీ ఎంపికలు", + "light": "వెలుతురు", + "link_options": "లింక్ ఎంపికలు", + "linked_oauth_account": "లింక్ చేయబడిన OAuth ఖాతా", + "list": "జాబితా", + "loading": "లోడ్", + "loading_search_results_failed": "శోధన ఫలితాలను లోడ్ చేయడం విఫలమైంది", + "log_out": "లాగ్ అవుట్", + "log_out_all_devices": "అన్ని పరికరాలను లాగ్ అవుట్ చేయండి", + "logged_out_all_devices": "అన్ని పరికరాలను లాగ్ అవుట్ చేసారు", + "logged_out_device": "పరికరం లాగ్ అవుట్ చేయబడింది", + "logout_this_device_confirmation": "మీరు ఖచ్చితంగా ఈ పరికరాన్ని లాగ్ అవుట్ చేయాలనుకుంటున్నారా?", + "longitude": "రేఖాంశం", + "look": "చూడు", + "loop_videos": "లూప్ వీడియోలు", + "loop_videos_description": "వివరాల వ్యూయర్‌లో వీడియోను స్వయంచాలకంగా లూప్ చేయడానికి ప్రారంభించండి.", + "make": "తయారు చేయండి", + "manage_shared_links": "భాగస్వామ్య లింక్‌లను నిర్వహించండి", + "manage_sharing_with_partners": "భాగస్వాములతో భాగస్వామ్యాన్ని నిర్వహించండి", + "manage_the_app_settings": "యాప్ సెట్టింగ్‌లను నిర్వహించండి", + "manage_your_account": "మీ ఖాతా నిర్వహించుకొనండి", + "manage_your_oauth_connection": "మీ OAuth కనెక్షన్‌ని నిర్వహించండి", + "map": "మ్యాప్", + "map_marker_with_image": "చిత్రంతో మ్యాప్ మార్కర్", + "map_settings": "మ్యాప్ సెట్టింగ్‌లు", + "matches": "మ్యాచ్‌లు", + "media_type": "మీడియా రకం", + "memories": "జ్ఞాపకాలు", + "memories_setting_description": "మీ జ్ఞాపకాలలో మీరు చూసే వాటిని నిర్వహించండి", + "memory": "గ్నాపకం", + "menu": "మెను", + "merge": "విలీనం", + "merge_people": "వ్యక్తులను విలీనం చేయండి", + "merge_people_limit": "మీరు ఒకేసారి 5 ముఖాలను మాత్రమే విలీనం చేయగలరు", + "merge_people_prompt": "మీరు ఈ వ్యక్తులను విలీనం చేయాలనుకుంటున్నారా? ఈ చర్య తిరుగులేనిది.", + "merge_people_successfully": "వ్యక్తులను విజయవంతంగా విలీనం చేసారు", + "minimize": "తగ్గించండి", + "minute": "నిమిషం", + "missing": "తప్పిపోయింది", + "model": "మోడల్", + "month": "నెల", + "more": "మరింత", + "moved_to_trash": "ట్రాష్‌కి తరలించబడింది", + "my_albums": "నా ఆల్బమ్‌లు", + "name": "పేరు", + "name_or_nickname": "పేరు లేదా మారుపేరు", + "never": "ఎప్పుడు కాదు", + "new_album": "కొత్త ఆల్బమ్", + "new_password": "కొత్త పాస్వర్డ్", + "new_person": "కొత్త వ్యక్తి", + "new_user_created": "కొత్త వినియోగదారి సృష్టించబడ్డారు", + "newest_first": "మొదటిది సరికొత్తది", + "next": "తరువాత", + "next_memory": "తదుపరి జ్ఞాపకం", + "no": "కాదు", + "no_albums_message": "మీ ఫోటోలు మరియు వీడియోలను నిర్వహించడానికి ఆల్బమ్‌ను సృష్టించండి", + "no_albums_with_name_yet": "మీకు ఇంకా ఈ పేరుతో ఆల్బమ్‌లు ఏవీ లేనట్లు కనిపిస్తోంది.", + "no_albums_yet": "మీ వద్ద ఇంకా ఆల్బమ్‌లు ఏవీ లేనట్లు కనిపిస్తోంది.", + "no_archived_assets_message": "మీ ఫోటోల వీక్షణ నుండి వాటిని దాచడానికి ఫోటోలు మరియు వీడియోలను ఆర్కైవ్ చేయండి", + "no_assets_message": "మీ మొదటి ఫోటోను అప్‌లోడ్ చేయడానికి క్లిక్ చేయండి", + "no_duplicates_found": "నకిలీలు ఏవీ కనుగొనబడలేదు.", + "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_unlimited_quota": "గమనిక: అపరిమిత కోటా కోసం 0ని నమోదు చేయండి", + "notes": "గమనికలు", + "notification_toggle_setting_description": "ఇమెయిల్ నోటిఫికేషన్‌లను ప్రారంభించండి", + "notifications": "నోటిఫికేషన్‌లు", + "notifications_setting_description": "నోటిఫికేషన్‌లను నిర్వహించండి", + "oauth": "OAuth", + "unsaved_change": "సేవ్ చేయని మార్పు", + "unselect_all": "ఎంచుకున్నవన్నీ తొలగించు", + "unselect_all_duplicates": "అన్ని నకిలీల ఎంపికను తీసివేయండి", + "unstack": "అన్-స్టాక్", + "untracked_files": "అన్‌ట్రాక్ చేయబడిన ఫైల్‌లు", + "untracked_files_decription": "ఈ ఫైల్‌లు అప్లికేషన్ ద్వారా ట్రాక్ చేయబడవు. అవి విఫలమైన కదలికలు, అంతరాయం కలిగించిన అప్‌లోడ్‌లు లేదా బగ్ కారణంగా మిగిలిపోయిన ఫలితాలు కావచ్చు", + "up_next": "తదుపరి", + "updated_password": "నవీకరించబడిన పాస్‌వర్డ్", + "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_usage_detail": "వినియోగదారు వినియోగ వివరాలు", + "username": "వినియోగదారి పేరు", + "users": "వినియోగదారులు", + "utilities": "యుటిలిటీస్", + "validate": "ధృవీకరించండి", + "variables": "వేరియబుల్స్", + "video": "వీడియో", + "video_hover_setting": "థంబ్‌నెయిల్ పైనా హోవర్ చేయగానే వీడియో ప్లే చెయ్", + "video_hover_setting_description": "థంబ్‌నెయిల్ పైనా హోవర్ చేయగానే చిహ్నం ప్లే చేయు. నిలిపివేయబడినప్పటికీ, ప్లే చిహ్నంపై హోవర్ చేయడం ద్వారా ప్లేబ్యాక్ ప్రారంభించబడుతుంది.", + "videos": "వీడియోలు", + "view": "చూడండి", + "view_album": "ఆల్బమ్‌ని వీక్షించండి", + "view_all": "అన్నీ వీక్షించండి", + "view_all_users": "వినియోగదారులందరినీ వీక్షించండి", + "view_links": "లింక్‌లను వీక్షించండి", + "view_next_asset": "తదుపరి ఆస్తిని వీక్షించండి", + "view_previous_asset": "మునుపటి ఆస్తిని వీక్షించండి", + "view_stack": "స్టాక్ చూడండి", + "waiting": "వేచి ఉంది", + "warning": "హెచ్చరిక", + "week": "వారం", + "welcome": "స్వాగతం" +} diff --git a/web/src/lib/i18n/th.json b/i18n/th.json similarity index 56% rename from web/src/lib/i18n/th.json rename to i18n/th.json index d7348f37e2..e964bdd198 100644 --- a/web/src/lib/i18n/th.json +++ b/i18n/th.json @@ -1,7 +1,7 @@ { "about": "เกี่ยวกับ", "account": "บัญชี", - "account_settings": "ตั้งค่าบัญชี", + "account_settings": "การตั้งค่าบัญชี", "acknowledge": "รับทราบ", "action": "การดำเนินการ", "actions": "การดำเนินการ", @@ -17,18 +17,19 @@ "add_import_path": "เพิ่มพาธนำเข้า", "add_location": "เพิ่มตำแหน่ง", "add_more_users": "เพิ่มผู้ใช้งาน", - "add_partner": "เพิ่มพันธมิตร", + "add_partner": "เพิ่มคู่หู", "add_path": "เพิ่มพาธ", "add_photos": "เพิ่มรูปภาพ", "add_to": "เพิ่มเข้า...", "add_to_album": "เพิ่มเข้าอัลบั้ม", - "add_to_shared_album": "เพิ่มเข้าอัลบั้มที่แชร์", + "add_to_shared_album": "เพิ่มลงในอัลบั้มที่แชร์กัน", "added_to_archive": "เพิ่มเข้าที่เก็บถาวร", "added_to_favorites": "เพิ่มเข้ารายการโปรด", - "added_to_favorites_count": "{count} รูปถูกเพิ่มเข้ารายการโปรด", + "added_to_favorites_count": "{count, number} รูปถูกเพิ่มเข้ารายการโปรด", "admin": { - "add_exclusion_pattern_description": "เพิ่มรูปแบบการยกเว้น การ Glob โดยใช้ *, ** และ ? ถูกรองรับ ถ้าต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีใดๆที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", - "authentication_settings": "ตั้งค่าการเข้าถึง", + "add_exclusion_pattern_description": "เพิ่มรูปแบบข้อยกเว้น รองรับการใช้ *, ** และ ? หากต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", + "asset_offline_description": "Immich", + "authentication_settings": "การตั้งค่าการเข้าถึง", "authentication_settings_description": "จัดการรหัสผ่าน, OAuth, และตั้งค่าการเข้าถึงอื่นๆ", "authentication_settings_disable_all": "คุณแน่ใจว่าต้องการปิดวิธีการล็อกอินทั้งหมดหรือไม่? ล็อกอินจะถูกปิดทั้งหมด", "authentication_settings_reenable": "เพื่อเปิดใหม่ ให้ใช้คำสั่งเซิร์ฟเวอร์", @@ -37,57 +38,58 @@ "cleared_jobs": "เคลียร์งานสำหรับ: {job}", "config_set_by_file": "ปัจจุบันการกำหนดค่าถูกตั้งค่าโดยไฟล์กำหนดค่า", "confirm_delete_library": "คุณแน่ใจว่าอยากลบคลังภาพ {library} หรือไม่?", - "confirm_delete_library_assets": "คุณแน่ใจว่าอยากลบคลังภาพนี้หรือไม่? การกระทำนี้จะลบ {count, plural, one {# สี่อในคลัง} other {# สี่อในคลังทั้งหมด}} ออกจาก Immich โดยถาวรและไม่สามารถยกเลิกได้ ไฟล์จะยังคงอยู่บนดิสก์", - "confirm_email_below": "เพื่อยืนยัน พิมพ์ \"{email}\" ด้านล่าง", - "confirm_reprocess_all_faces": "คุณแน่ใจว่าคุณต้องการประมวลผลใบหน้าทั้งหมดใหม่หรือไม่? คนที่มีชื่อจะถูกลบไปด้วย", + "confirm_delete_library_assets": "คุณแน่ใจว่าอยากลบคลังภาพนี้หรือไม่? สี่อทั้งหมด {count} สี่อในคลังจะถูกลบออกจาก Immich โดยถาวร ไฟล์จะยังคงอยู่บนดิสก์", + "confirm_email_below": "เพื่อยืนยัน พิมพ์ \"{email}\" ข้างล่าง", + "confirm_reprocess_all_faces": "คุณแน่ใจว่าคุณต้องการประมวลผลใบหน้าทั้งหมดใหม่? ชื่อคนจะถูกลบไปด้วย", "confirm_user_password_reset": "คุณแน่ใจว่าต้องการรีเซ็ตรหัสผ่านของ {user} หรือไม่?", "crontab_guru": "Crontab Guru", "disable_login": "ปิดการล็อกอิน", "disabled": "", "duplicate_detection_job_description": "ใช้ machine learning กับสี่อเพื่อตรวจจับรูปภาพที่คล้ายกัน โดยใช้การค้นหาอัจฉริยะ", - "exclusion_pattern_description": "รูปแบบการยกเว้นสามารถละเว้นไฟล์และโฟลเดอร์ขณะสแกนคลังภาพของคุณ มีประโยชน์เมื่อมีโฟลเดอร์ที่มีไฟล์ที่ไม่อยากนำเข้า เช่นไฟล์ RAW", + "exclusion_pattern_description": "ข้อยกเว้นสามารถละเว้นไฟล์และโฟลเดอร์ขณะสแกนคลังภาพของคุณ มีประโยชน์เมื่อโฟลเดอร์มีไฟล์ที่ไม่อยากนำเข้า เช่นไฟล์ RAW", "external_library_created_at": "คลังภาพภายนอก (ถูกสร้างเมื่อ {date})", "external_library_management": "การจัดการคลังภาพภายนอก", "face_detection": "การตรวจจับใบหน้า", - "face_detection_description": "ตรวจจับใบหน้าในสี่อโดยใช้ machine learning สำหรับวิดีโอ จะใช้ภาพตัวอย่างจากวิดีโอเท่านั้น \"ทั้งหมด\" จะประมวลผลสี่อทั้งหมด \"ขาดหาย\" จะประมวลผลสี่อที่ยังไม่ได้ประมวลผล ใบหน้าที่ถูกตรวจจับแล้วจะถูกเข้าคิวประมวลผลการจดจำใบหน้า เพิ่มเข้าไปในกลุ่มที่มีอยู่แล้วหรือคนใหม่", + "face_detection_description": "ตรวจจับใบหน้าในสี่อโดยใช้ machine learning วิดีโอจะใช้ภาพตัวอย่างจากวิดีโอเท่านั้น \"ทั้งหมด\" จะประมวลผลสี่อทั้งหมด \"ขาดหาย\" จะประมวลผลสี่อที่ยังไม่ได้ประมวลผล ใบหน้าที่ถูกตรวจจับแล้วจะถูกเข้าคิวประมวลผลการจดจำใบหน้า เพิ่มเข้าไปในกลุ่มที่มีอยู่แล้วหรือคนใหม่", "facial_recognition_job_description": "นำใบหน้าที่ตรวจจับได้ไปจับกลุ่มตามผู้คน ขั้นตอนนี้ทำงานหลังจากตรวจจับใบหน้าสำเร็จ \"ทั้งหมด\" จะจำกลุ่มใบหน้าทั้งหมดใหม่ \"ขาดหาย\" จะจัดคิวใบหน้าที่ยังไม่ได้ระบุคน", "failed_job_command": "คำสั่ง {command} ของงาน {job} ล้มเหลว", - "force_delete_user_warning": "คําเตือน: ขั้นตอนนี้จะลบผู้ใช้และสื่อทั้งหมดทันที ขั้นตอนนี้จะย้อนกลับมาไม่ได้และกู้คืนไฟล์ไม่ได้.", + "force_delete_user_warning": "คําเตือน: ขั้นตอนนี้จะลบผู้ใช้งานและสื่อทั้งหมดทันที ไม่สามารถย้อนกลับมาได้และกู้คืนไฟล์ไม่ได้", "forcing_refresh_library_files": "บังคับรีเฟรชไฟล์ทั้งหมด", - "image_format_description": "WebP จะสร้างไฟล์ที่เล็กกว่า JPEG แต่ใช้เวลา encode นานกว่า", + "image_format": "Format", + "image_format_description": "WebP จะให้ไฟล์ที่เล็กกว่า JPEG แต่ใช้เวลาแปลงไฟล์นานกว่า", "image_prefer_embedded_preview": "ใช้พรีวิวแบบฝังตัว", "image_prefer_embedded_preview_setting_description": "ใช้พรีวิวฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพถ้ามี แต่คุณภาพรูปภาพขึ้นอยู่กับกล้อง และอาจจะมีสิ่งตกค้างจากการย่อขนาดไฟล์", "image_prefer_wide_gamut": "ใช้ช่วงสีกว้าง", - "image_prefer_wide_gamut_setting_description": "ใช้ Display P3 สำหรับภาพย่อ ซึ่งจะรักษาความมีชีวิตชีวาของภาพที่ใช้ปริภูมิสีกว้าง แต่ภาพบนอุปกรณ์หรือเบราว์เซอร์เก่าอาจปรากฏแตกต่างออกไป ภาพ sRGB จะถูกเก็บเป็น sRGB เพื่อป้องกันไม่ให้สีเคลื่อน", + "image_prefer_wide_gamut_setting_description": "ใช้การแสดงผลแบบ P3 สําหรับภาพตัวอย่าง คงความเข้มและความกว้างขอบเขตสี แต่ภาพอาจดูแตกต่างกันในอุปกรณ์เก่าที่มีเว็บเบราว์เซอร์รุ่นเก่า ภาพ sRGB จะถูกเก็บในรูปแบบ sRGB เพื่อลดการเคลื่อนของสี", "image_preview_format": "รูปแบบพรีวิว", "image_preview_resolution": "ความละเอียดพรีวิว", "image_preview_resolution_description": "ใช้เมื่อดูรูปเดียวและสำหรับ machine learning ความละเอียดสูงสามารถเก็บรายละเอียดดีกว่าแต่ใช้เวลา encode นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป", "image_quality": "คุณภาพ", "image_quality_description": "คุณภาพรูปจาก 1-100 ค่าสูงมีคุณภาพสูงกว่าแต่ขนาดไฟล์ใหญ่กว่า ตัวเลือกนี้ส่งผลต่อภาพพรีวิวและภาพขนาดย่อ", - "image_settings": "ตั้งค่ารูปภาพ", - "image_settings_description": "จัดการคุณภาพและความละเอียดของภาพที่สร้างขึ้น", + "image_settings": "การตั้งค่ารูปภาพ", + "image_settings_description": "จัดการคุณภาพและความคมชัดของภาพที่สร้างขึ้น", "image_thumbnail_format": "รูปแบบภาพย่อ", "image_thumbnail_resolution": "ความละเอียดภาพย่อ", "image_thumbnail_resolution_description": "ใช้เมื่อดูกลุ่มรูปภาพ (ไทม์ไลน์หลัก, หน้าอัลบั้ม, ฯลฯ) ความสะเอียดที่สูงกว่าจะเก็บรายละเอียดได้มากกว่าแต่ใช้เวลา encode นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป", - "job_concurrency": "{job} พร้อมกัน", + "job_concurrency": "{job} งานพร้อมกัน", "job_not_concurrency_safe": "งานนี้ทำงานพร้อมกันแบบปลอดภัยไม่ได้", "job_settings": "การตั้งค่างาน", "job_settings_description": "จัดการการทำหลายงานพร้อมกัน", "job_status": "สถานะงาน", - "jobs_delayed": "{jobCount, plural, other {# ล่าช้า}}", - "jobs_failed": "{jobCount, plural, other {# ล้มเหลว}}", + "jobs_delayed": "{jobCount} งานล่าช้า", + "jobs_failed": "{jobCount} งานล้มเหลว", "library_created": "สร้างคลังภาพ: {library}", "library_cron_expression": "รูปแบบ Cron", "library_cron_expression_description": "ตั้งช่วงเวลาสแกนโดยใช้รูปแบบ cron สามารถดูข้อมูลเพิ่มเติมได้ที่ Crontab Guru", "library_cron_expression_presets": "แม่แบบรูปแบบ Cron", "library_deleted": "คลังภาพถูกลบ", - "library_import_path_description": "ระบุโฟลเดอร์เพื่อนําเข้า โฟลเดอร์นี้และโฟลเดอร์ย่อยจะถูกค้นหาภาพและวิดีโอ", + "library_import_path_description": "ระบุโฟลเดอร์เพื่อนําเข้า โฟลเดอร์นี้และโฟลเดอร์ย่อยจะถูกค้นหาภาพและวิดีโอ.", "library_scanning": "การสแกนเป็นระยะ", - "library_scanning_description": "ตั้งค่าการสแกนเป็นระยะ", - "library_scanning_enable_description": "เปิดการสแกนเป็นระยะ", + "library_scanning_description": "ตั้งค่าการสแกนคลังภาพเป็นระยะ", + "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": "หาไฟล์ที่เปลี่ยนแปลงโดยอัตโนมัติ", @@ -102,12 +104,12 @@ "machine_learning_duplicate_detection_setting_description": "ใช้ CLIP เพื่อแสดงที่มีแนวโน้มซ้ํา", "machine_learning_enabled": "เปิดใช้ machine learning", "machine_learning_enabled_description": "หากปิดใช้งาน คุณสมบัติ ML ทั้งหมดจะปิดการใช้งานโดยไม่คํานึงถึงการตั้งค่าด้านล่าง.", - "machine_learning_facial_recognition": "การตรวจจับใบหน้า", - "machine_learning_facial_recognition_description": "ตรวจจับ จำแนก และรวมกลุ่มใบหน้าในภาพ", - "machine_learning_facial_recognition_model": "โมเดลสำหรับการตรวจจับใบหน้า", + "machine_learning_facial_recognition": "การจดจำใบหน้า", + "machine_learning_facial_recognition_description": "ตรวจจับ จดจำ และจำแนกใบหน้าในภาพ", + "machine_learning_facial_recognition_model": "โมเดลสำหรับการจดจำใบหน้า", "machine_learning_facial_recognition_model_description": "โมเดลเรียงตามขนาดลดหลั่นลงมา โมเดลที่ใหญ่กว่าจะประมวลผลช้ากว่าและใช้หน่วยความจำมากกว่า แต่ให้ผลลัพธ์ที่ดีขึ้น หมายเหตุไว้ว่าเมื่อเปลี่ยนโมเดล คุณต้องรันงานตรวจจับใบหน้าทุกภาพใหม่ทั้งหมด", "machine_learning_facial_recognition_setting": "เปิดใช้การจดจําใบหน้า", - "machine_learning_facial_recognition_setting_description": "หากปิดใช้งาน จะไม่มีการตรวจจับใบหน้าบนรูปภาพและจะไม่มีส่วนผู้คนในหน้าเว็บ", + "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": "ระยะทางการจดจำสูงสุด", @@ -116,8 +118,8 @@ "machine_learning_min_detection_score_description": "ค่าความมั่นใจในการตรวจจับใบหน้า จาก 0-1 ค่ายิ่งต่ำจะยิ่งตรวจจับใบหน้ามากขึ้น แต่อาจมีผลผิดพลาด", "machine_learning_min_recognized_faces": "จดจำใบหน้าขั้นต่ำ", "machine_learning_min_recognized_faces_description": "จำนวนใบหน้าขั้นต่ำที่จะสร้างคนขึ้นมา การเพิ่มค่านี้จะทำให้การจดจำใบหน้าแม่นยำกว่าแต่เพิ่มโอกาสที่ใบหน้าจะไม่ถูกมอบหมายให้กับบุคคล", - "machine_learning_settings": "การตั้งค่า Machine Learning", - "machine_learning_settings_description": "การจัดการฟีเจอร์และการตั้งค่า machine learning", + "machine_learning_settings": "การตั้งค่า machine learning", + "machine_learning_settings_description": "จัดการการตั้งค่า machine learning", "machine_learning_smart_search": "การค้นหาอัจฉริยะ", "machine_learning_smart_search_description": "ค้นหาภาพโดยใช้ความหมายจากการใช้ CLIP", "machine_learning_smart_search_enabled": "เปิดใช้งานการค้นหาอัจฉริยะ", @@ -129,16 +131,21 @@ "map_enable_description": "เปิดใช้งานแผนที่", "map_gps_settings": "การตั้งค่าแผนที่และ GPS", "map_gps_settings_description": "จัดการการตั้งค่าแผนที่และ GPS (Reverse Geocoding)", + "map_implications": "ฟีเจอร์แผนที่ต้องการบริการแผ่นแผนที่จากภายนอก (tiles.immich.cloud)", "map_light_style": "แบบสว่าง", "map_manage_reverse_geocoding_settings": "จัดการการตั้งค่าแปลงพิกัดภูมิศาสตร์ ", "map_reverse_geocoding": "ประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_enable_description": "เปิดใช้งานประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_settings": "การตั้งค่าประมวลผลชื่อทางภูมิศาสตร์", - "map_settings": "การตั้งค่าแผนที่", + "map_settings": "การตั้งค่าแผนที่และ GPS", "map_settings_description": "จัดการการตั้งค่าแผนที่", "map_style_description": "URL ไปยังธีมแผนที่ style.json", "metadata_extraction_job": "ดึงข้อมูล metadata", - "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความละเอียด", + "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความคมชัด", + "metadata_faces_import_setting": "เปิดการนำเข้าข้อมูลใบหน้า", + "metadata_faces_import_setting_description": "นำเข้าข้อมูลใบหน้าจาก EXIF ของไฟล์ภาพและไฟล์ประกอบ", + "metadata_settings": "การตั้งค่า Metadata", + "metadata_settings_description": "จัดการการตั้งค่า Metadata", "migration_job": "การโยกย้าย", "migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด", "no_paths_added": "ไม่ได้เพิ่มพาธ", @@ -153,11 +160,11 @@ "notification_email_ignore_certificate_errors_description": "ไม่สนใจการยืนยันใบรับรอง TLS ผิดพลาด (ไม่แนะนำ)", "notification_email_password_description": "รหัสผ่านที่ใช้เมื่อเข้าถึงเซิร์ฟเวอร์อีเมล", "notification_email_port_description": "พอร์ตของเซิร์ฟเวอร์อีเมล (เช่น 25, 465, หรือ 587)", - "notification_email_sent_test_email_button": "ส่งอีเมลทดสอบและบันทึก", + "notification_email_sent_test_email_button": "ส่งอีเมลทดลองและบันทึก", "notification_email_setting_description": "การตั้งค่าสำหรับการส่งการแจ้งเตือนอีเมล", "notification_email_test_email": "ส่งอีเมลทดลอง", - "notification_email_test_email_failed": "ส่งอีเมลทดลองล้มเหลว โปรดตรวจสอบค่าที่ตั้ง", - "notification_email_test_email_sent": "อีเมลทดสอบถูกส่งไปยัง {email} กรุณาตรวจสอบกล่องจดหมาย", + "notification_email_test_email_failed": "ส่งอีเมลทดลองล้มเหลว โปรดตรวจสอบค่าที่ตั้งไว้", + "notification_email_test_email_sent": "อีเมลทดลองถูกส่งไปยัง {email} กรุณาตรวจสอบกล่องจดหมาย", "notification_email_username_description": "ชื่อผู้ใช้งานเมื่อเข้าถึงเซิร์ฟเวอร์อีเมล", "notification_enable_email_notifications": "เปิดการแจ้งเตือนผ่านอีเมล", "notification_settings": "การตั้งค่าการแจ้งเตือน", @@ -173,7 +180,9 @@ "oauth_issuer_url": "ผู้ออก URL", "oauth_mobile_redirect_uri": "URI เปลี่ยนเส้นทางบนโทรศัพท์", "oauth_mobile_redirect_uri_override": "แทนที่ URI เปลี่ยนเส้นทางบนโทรศัพท์", - "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ 'app.immich:/' เป็น URI เปลี่ยนเส้นทางที่ไม่ถูกต้อง", + "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ 'app.immich:/' เป็น URI ที่เปลี่ยนเส้นทางไม่ถูกต้อง", + "oauth_profile_signing_algorithm": "อัลกอริทึมการรับรองบัญชีผู้ใช้", + "oauth_profile_signing_algorithm_description": "อัลกอริทึมใช้ในการรับรองบัญชีผู้ใช้", "oauth_scope": "ขอบเขต", "oauth_settings": "OAuth", "oauth_settings_description": "จัดการการตั้งค่าล็อกอินผ่าน OAuth", @@ -190,24 +199,24 @@ "password_enable_description": "ล็อกอินกับอีเมลและรหัสผ่าน", "password_settings": "ล็อกอินผ่านรหัสผ่าน", "password_settings_description": "จัดการการตั้งค่าของการล็อกอินผ่านรหัสผ่าน", - "paths_validated_successfully": "พาธทั้งหมดถูกตรวจสอบสำเร็จแล้ว", + "paths_validated_successfully": "เส้นทางทั้งหมดถูกตรวจสอบสำเร็จแล้ว", "quota_size_gib": "โควตา (GiB)", "refreshing_all_libraries": "รีเฟรชคลังภาพทั้งหมด", "registration": "ลงทะเบียนผู้จัดการ", "registration_description": "เนื่องจากคุณเป็นผู้ใช้งานแรกของระบบ คุณจะถูกแต่งตั้งเป็นผู้จัดการและรับผิดชอบงานบริหาร ผู้ใช้งานเพิ่มเติมจะถูกสร้างโดยคุณ", - "removing_offline_files": "กำลังลบไฟล์ออฟไลน์", + "removing_deleted_files": "กำลังลบไฟล์ออฟไลน์", "repair_all": "ซ่อมแซมทั้งหมด", "repair_matched_items": "จับคู่ {count, plural, one {# รายการ} other {# รายการ}}", "repaired_items": "ซ่อมแซม {count, plural, one {# รายการ} other {# รายการ}}", - "require_password_change_on_login": "บังคับผู้ใช้ให้เปลี่ยนรหัสผ่านเมื่อเข้าสู่ระบบครั้งแรก", + "require_password_change_on_login": "บังคับผู้ใช้งานให้เปลี่ยนรหัสผ่านเมื่อเข้าสู่ระบบครั้งแรก", "reset_settings_to_default": "ตั้งค่าการตั้งค่าเป็นค่าเริ่มต้น", "reset_settings_to_recent_saved": "ตั้งค่าการตั้งค่าเป็นค่าล่าสุด", "scanning_library_for_changed_files": "สแกนคลังภาพสำหรับไฟล์ที่เปลี่ยนไป", "scanning_library_for_new_files": "สแกนคลังภาพสำหรับไฟล์ใหม่", "send_welcome_email": "ส่งอีเมลต้อนรับ", "server_external_domain_settings": "โดเมนภายนอก", - "server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ รวม http(s)://", - "server_settings": "ตั้งค่าเซิร์ฟเวอร์", + "server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ แบบมี http(s)://", + "server_settings": "การตั้งค่าเซิร์ฟเวอร์", "server_settings_description": "จัดการการตั้งค่าเซิร์ฟเวอร์", "server_welcome_message": "ข้อความต้อนรับ", "server_welcome_message_description": "ข้อความที่แสดงบนหน้าล็อกอิน", @@ -218,13 +227,13 @@ "storage_template_date_time_description": "เวลาประทับบนสื่อถูกใช้สำหรับข้อมูลวันเวลา", "storage_template_date_time_sample": "ตัวอย่างเวลา {date}", "storage_template_enable_description": "เปิดใช้งานการจัดเทมเพลตที่เก็บข้อมูล", - "storage_template_hash_verification_enabled": "เปิดใช้การตรวจสอบ Hash แล้ว", + "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_settings": "", - "storage_template_settings_description": "", + "storage_template_settings": "เทมเพลตการจัดเก็บข้อมูล", + "storage_template_settings_description": "จัดการโครงสร้างโฟลเดอร์และชื่อไฟล์ที่อัพโหลด", "system_settings": "การตั้งค่าระบบ", "theme_custom_css_settings": "CSS กําหนดเอง", "theme_custom_css_settings_description": "Cascading Style Sheets ช่วยให้ปรับแต่งเค้าโครง Immich ได้", @@ -243,14 +252,14 @@ "transcoding_accepted_audio_codecs": "แบบไฟล์เสียงที่ยอมรับ", "transcoding_accepted_audio_codecs_description": "เลือกแบบไฟล์เสียงที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฏการแปลงแบบไฟล์", "transcoding_accepted_video_codecs": "แบบไฟล์วิดีโอที่ยอมรับ", - "transcoding_accepted_video_codecs_description": "เลือกแบบไฟล์วิดีโอที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฏการแปลงแบบไฟล์", - "transcoding_advanced_options_description": "ตัวเลือกที่ผู้ใช้ส่วนใหญ่ไม่จำเป็นต้องเปลี่ยน", + "transcoding_accepted_video_codecs_description": "เลือกแบบไฟล์วิดีโอที่จะไม่ถูกแปลงใหม่ ใช้สําหรับกฎการแปลงแบบไฟล์", + "transcoding_advanced_options_description": "ตัวเลือกที่ผู้ใช้งานส่วนใหญ่ไม่จำเป็นต้องเปลี่ยน", "transcoding_audio_codec": "แบบไฟล์เสียง", - "transcoding_audio_codec_description": "Opus ให้คุณภาพสูงสุด แต่อุปกรณ์เก่าหรือซอฟต์แวร์เก่าอาจจะเข้ากันไม่ได้", + "transcoding_audio_codec_description": "Opus ให้คุณภาพสูงสุด แต่อาจจะเข้ากันไม่ได้กับอุปกรณ์เก่าหรือซอฟต์แวร์เก่า", "transcoding_bitrate_description": "วิดีโอมีค่า bitrate สูงกว่าค่าสูงสุดหรือไฟล์วิดีโอไม่รองรับ", "transcoding_constant_quality_mode": "โหมดคุณภาพคงที่", - "transcoding_constant_quality_mode_description": "ICQ ดีกว่า CQP แต่อุปกรณ์บางตัวอาจจะไม่รองรับโหมดนี้ การตั้งค่าตัวนี้จะเลือกโหมดที่ระบุไว้เมื่อใช้การแปลงคุณภาพไฟล์ ไม่สน NVENC เพราะไม่รองรับ ICQ", - "transcoding_constant_rate_factor": "ปัจจัยค่าคงที่ (-crf)", + "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_hardware_acceleration": "การเร่งความเร็วด้วยฮาร์ดแวร์", @@ -264,7 +273,7 @@ "transcoding_max_bitrate_description": "การตั้งค่า bitrate สูงสุดจะสามารถคาดเดาขนาดไฟล์ได้มากขึ้นโดยไม่กระทบคุณภาพ สำหรับความคมชัด 720p ค่าทั่วไปคือ 2600k สําหรับ VP9 หรือ HEVC, 4500k สําหรับ H.264 ปิดการตั้งค่าเมี่อตั้งค่าเป็น 0", "transcoding_max_keyframe_interval": "", "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "วิดีโอที่สูงกว่าความละเอียดเป้าหมายหรือไม่ได้อยู่ในรูปแบบที่รองรับ", + "transcoding_optimal_description": "วีดิโอมีความคมชัดสูงกว่าเป้าหมายหรืออยู่ในรูปแบบที่รับไม่ได้", "transcoding_preferred_hardware_device": "", "transcoding_preferred_hardware_device_description": "", "transcoding_preset_preset": "", @@ -273,91 +282,91 @@ "transcoding_reference_frames_description": "", "transcoding_required_description": "", "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "ความละเอียดเป้าหมาย", - "transcoding_target_resolution_description": "", + "transcoding_settings_description": "จัดการข้อมูลความคมชัดและแบบไฟล์วิดีโอ", + "transcoding_target_resolution": "เป้าหมายความคมชัด", + "transcoding_target_resolution_description": "ความคมชัดที่สูงกว่าจะเก็บรายละเอียดดีกว่าแต่ใช้เวลาแปลงไฟล์นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอพ", "transcoding_temporal_aq": "", "transcoding_temporal_aq_description": "", "transcoding_threads": "เธรด", - "transcoding_threads_description": "", + "transcoding_threads_description": "ค่ายิ่งเยอะจะแปลงไฟล์เร็วกว่า แต่จะเหลือพื้นที่ให้เซิร์ฟเวอร์ประมวลผลงานอื่นน้อยลงเมื่อทํางานนี้ ค่านี้ไม่ควรมากกว่าจํานวน CPU core จะประมวลผลเต็มที่เมื่อตั้งเป็น 0", "transcoding_tone_mapping": "การฉายโทนสี", "transcoding_tone_mapping_description": "", "transcoding_tone_mapping_npl": "", "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", + "transcoding_transcode_policy": "กฎการแปลงไฟล์", + "transcoding_two_pass_encoding": "การแปลงไฟล์สองรอบ", + "transcoding_two_pass_encoding_setting_description": "การแปลงไฟล์สองรอบจะช่วยให้ได้วิดีโอที่ดีขึ้น เมื่อเปิดใช้งาน bitrate สูงสุด (จำเป็นสำหรับไฟล์ H.264 และ HEVC) โหมดนี้จะใช้ช่วง bitrate ที่ขึ้นอยู่กับค่า bitrate สูงสุดและไม่สนใจ CRF สำหรับ VP9 สามารถใช้ค่า CRF ได้ถ้าปิดใช้งาน bitrate สูงสุด", + "transcoding_video_codec": "แบบไฟล์วิดีโอ", + "transcoding_video_codec_description": "VP9 มีประสิทธิภาพสูงและเข้ากันกับเว็บได้ดี แต่ใช้เวลาแปลงไฟล์นานกว่า HEVC มีประสิทธิภาพคล้ายกัน แต่เข้ากันกับเว็บได้น้อยกว่า H.264 เข้ากันกับทุกอุปกรณ์ และแปลงไฟล์เร็ว แต่ได้ไฟล์ที่ใหญ่ขึ้น AV1 เป็นไฟล์ที่มีประสิทธิภาพมากที่สุด แต่ไม่เข้ากันกับอุปกรณ์เก่า", + "trash_enabled_description": "เปิดใช้งานถังขยะ", + "trash_number_of_days": "จํานวนวัน", + "trash_number_of_days_description": "จํานวนวันที่เก็บสื่อไว้ในถังขยะก่อนที่จะลบถาวร", "trash_settings": "การตั้งค่าถังขยะ", "trash_settings_description": "จัดการการตั้งค่าถังขยะ", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_settings": "", - "user_settings_description": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job_description": "" + "user_delete_delay_settings": "ลบการถ่วงเวลา", + "user_delete_delay_settings_description": "จํานวนวันหลังจากที่เอาออกเพื่อลบบัญชีผู้ใช้และสื่อถาวร งานลบบัญชีผู้ใช้ทํางานทุกเที่ยงคืนเพื่อตรวจสอบผู้ใช้ที่พร้อมที่จะถูกลบข้อมูลแล้ว การตั้งค่าครั้งนี้จะมีผลครั้งต่อไป", + "user_settings": "การตั้งค่าผู้ใช้", + "user_settings_description": "จัดการการตั้งค่าผู้ใช้", + "version_check_enabled_description": "เช็ค GitHub เป็นระยะ ๆ เพื่อตรวจสอบรุ่นใหม่", + "version_check_settings": "ตรวจสอบรุ่น", + "version_check_settings_description": "เปิด/ปิดการแจ้งเตือนรุ่นใหม่", + "video_conversion_job_description": "แปลงไฟล์วิดีโอเพึ่อรองรับบราวเซอร์และเครื่องเล่นอื่น ๆ มากขึ้น" }, - "admin_email": "อีเมลผู้ดูแล", - "admin_password": "รหัสผ่านผู้ดูแล", - "administration": "การจัดการ", + "admin_email": "อีเมลผู้ดูแลระบบ", + "admin_password": "รหัสผ่านผู้ดูแลระบบ", + "administration": "การดูแลระบบ", "advanced": "ขั้นสูง", "age_months": "อายุ {months, plural, one {# เดือน} other {# เดือน}}", "age_year_months": "อายุ 1 ปี {months, plural, one {# เดือน} other {# เดือน}}", "age_years": "{years, plural, other {อายุ #}}", "album_added": "เพิ่มอัลบั้มแล้ว", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", + "album_added_notification_setting_description": "แจ้งเตือนอีเมลเมื่อคุณถูกเพิ่มไปในอัลบั้มที่แชร์กัน", + "album_cover_updated": "อัพเดทหน้าปกอัลบั้มแล้ว", + "album_info_updated": "อัพเดทข้อมูลอัลบั้มแล้ว", + "album_name": "ชื่ออัลบั้ม", + "album_options": "ตัวเลือกอัลบั้ม", + "album_updated": "อัพเดทอัลบั้มแล้ว", + "album_updated_setting_description": "แจ้งเตือนอีเมลเมื่ออัลบั้มที่แชร์กันมีสื่อใหม่", "albums": "อัลบั้ม", "all": "ทั้งหมด", "all_albums": "อัลบั้มทั้งหมด", - "all_people": "ผู้คนทั้งหมด", + "all_people": "ทุกคน", "all_videos": "วิดีโอทั้งหมด", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "คีย์ API", - "api_keys": "คีย์ API", - "app_settings": "การตั้งค่าแอป", - "appears_in": "ปรากฏอยู่ใน", + "allow_dark_mode": "อนุญาตโหมดมืด", + "allow_edits": "อนุญาตให้แก้ไขได้", + "api_key": "กุญแจ API", + "api_keys": "กุญแจ API", + "app_settings": "การตั้งค่าแอพ", + "appears_in": "อยู่ใน", "archive": "เก็บถาวร", - "archive_or_unarchive_photo": "", + "archive_or_unarchive_photo": "เก็บ/ไม่เก็บภาพถาวร", "archived": "เก็บถาวร", "are_these_the_same_person": "เป็นคนเดียวกันหรือไม่?", - "asset_offline": "", + "asset_offline": "สื่อออฟไลน์", "asset_skipped": "ข้ามแล้ว", "asset_uploaded": "อัปโหลดแล้ว", "asset_uploading": "กำลังอัปโหลด...", - "assets": "ทรัพยากร", - "authorized_devices": "", + "assets": "สื่อ", + "authorized_devices": "อุปกรณ์ที่ได้รับอนุญาต", "back": "กลับ", "backward": "กลับหลัง", - "blurred_background": "", + "blurred_background": "พื้นหลังแบบเบลอ", "camera": "กล้อง", - "camera_brand": "", - "camera_model": "", + "camera_brand": "ยี่ห้อกล้อง", + "camera_model": "รุ่นกล้อง", "cancel": "ยกเลิก", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", + "cancel_search": "ยกเลิกการค้นหา", + "cannot_merge_people": "ไม่สามารถรวมกลุ่มคนได้", + "cannot_update_the_description": "ไม่สามารถอัพเดทรายละเอียดได้", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", + "change_date": "เปลี่ยนวันที่", "change_expiration_time": "เปลี่ยนเวลาหมดอายุ", - "change_location": "", - "change_name": "", - "change_name_successfully": "", + "change_location": "เปลี่ยนตําแหน่ง", + "change_name": "เปลี่ยนชื่อ", + "change_name_successfully": "เปลี่ยนชื่อเรียบร้อยแล้ว", "change_password": "เปลี่ยนรหัสผ่าน", "change_your_password": "", "changed_visibility_successfully": "", @@ -373,61 +382,61 @@ "comment_options": "", "comments_are_disabled": "", "confirm": "ยืนยัน", - "confirm_admin_password": "", + "confirm_admin_password": "ยืนยันรหัสผ่านผู้ดูแลระบบ", "confirm_password": "ยืนยันรหัสผ่าน", "contain": "มี", "context": "บริบท", "continue": "ต่อไป", - "copied_image_to_clipboard": "", - "copy_error": "", - "copy_file_path": "คัดลอกพาธไฟล์", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", + "copied_image_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_library": "สร้างคลังภาพ", "create_link": "สร้างลิงก์", "create_link_to_share": "สร้างลิงก์เพื่อแชร์", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "สร้าง", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", + "create_new_person": "สร้างคนใหม่", + "create_new_user": "สร้างผู้ใช้งานใหม่", + "create_user": "สร้างผู้ใช้", + "created": "สร้างแล้ว", + "current_device": "อุปกรณ์ปัจจุบัน", + "custom_locale": "ปรับภาษาท้องถิ่นเอง", + "custom_locale_description": "ใช้รูปแบบวันที่และตัวเลขจากภาษาและขอบเขต", "dark": "มืด", - "date_after": "", + "date_after": "วันที่หลังจาก", "date_and_time": "วันและเวลา", - "date_before": "", + "date_before": "วันที่ก่อน", "date_range": "ช่วงวันที่", "day": "วัน", - "default_locale": "", - "default_locale_description": "", + "default_locale": "ภาษาท้องถิ่นปกติ", + "default_locale_description": "ใช้รูปแบบวันที่และตัวเลขจากเบราว์เซอร์ของคุณ", "delete": "ลบออก", "delete_album": "ลบอัลบั้ม", - "delete_key": "", - "delete_library": "", - "delete_link": "", + "delete_key": "ลบกุญแจ", + "delete_library": "ลบคลังภาพ", + "delete_link": "ลบลิงก์", "delete_shared_link": "ลบลิงก์ที่แชร์", - "delete_user": "", - "deleted_shared_link": "", + "delete_user": "ลบผู้ใช้", + "deleted_shared_link": "ลบลิงก์ที่แชร์แล้ว", "description": "รายละเอียด", "details": "รายละเอียด", - "direction": "ทิศทาง", - "disallow_edits": "", + "direction": "เส้นทาง", + "disallow_edits": "ไม่อนุญาตให้แก้ไข", "discover": "ค้นพบ", - "dismiss_all_errors": "", - "dismiss_error": "", + "dismiss_all_errors": "ปฏิเสธข้อผิดพลาดทั้งหมด", + "dismiss_error": "ปฏิเสธข้อผิดพลาด", "display_options": "", "display_order": "", "display_original_photos": "", - "display_original_photos_setting_description": "", + "display_original_photos_setting_description": "เมื่อดูสื่อให้แสดงภาพต้นฉบับแทนภาพตัวอย่างเมื่อไฟล์สื่อเปิดได้บนเว็บ อาจทําให้แสดง ภาพได้ช้าลง", "done": "เสร็จ", "download": "ดาวน์โหลด", "downloading": "กำลังดาวน์โหลด", @@ -439,21 +448,21 @@ "months": "", "years": "" }, - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "แก้ไขพาธนำเข้า", - "edit_import_paths": "แก้ไขพาธนำเข้า", - "edit_key": "", + "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": "", + "edit_people": "แก้ไขผู้คน", + "edit_title": "แก้ไขชื่อ", + "edit_user": "แก้ไขผู้ใช้", "edited": "แก้ไขแล้ว", "editor": "ผู้แก้ไข", "email": "อีเมล", @@ -462,61 +471,61 @@ "empty_trash": "ทิ้งจากถังขยะ", "enable": "เปิดใช้งาน", "enabled": "เปิดใช้งาน", - "end_date": "", + "end_date": "วันสิ้นสุด", "error": "เกิดข้อผิดพลาด", - "error_loading_image": "", + "error_loading_image": "เกิดข้อผิดพลาดระหว่างโหลดภาพ", "errors": { "import_path_already_exists": "พาธนำเข้านี้มีอยู่แล้ว", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "unable_to_add_album_users": "ไม่สามารถเพิ่มผู้ใช้ไปยังอัลบั้มได้", + "unable_to_add_comment": "ไม่สามารถเพิ่มความเห็นได้", + "unable_to_add_partners": "ไม่สามารถเพิ่มคู่หูได้", + "unable_to_change_album_user_role": "ไม่สามารถเปลี่ยนบทบาทผู้ใช้ในอัลบั้มได้", + "unable_to_change_date": "ไม่สามารถเปลี่ยนวันที่ได้", + "unable_to_change_location": "ไม่สามารถเปลี่ยนตําแหน่งได้", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_create_admin_account": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "ไม่สามารถลบอัลบั้ม", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_create_library": "ไม่สามารถสร้างคลังภาพได้", + "unable_to_create_user": "ไม่สามารถสร้างผู้ใช้ได้", + "unable_to_delete_album": "ไม่สามารถลบอัลบั้มได้", + "unable_to_delete_asset": "ไม่สามารถลบสื่อได้", + "unable_to_delete_user": "ไม่สามารถลบผู้ใช้ได้", + "unable_to_empty_trash": "ไม่สามารถลบถังขยะได้", + "unable_to_enter_fullscreen": "ไม่สามารถเปิดเต็มจอได้", + "unable_to_exit_fullscreen": "ไม่สามารถออกโหมดเต็มจอได้", + "unable_to_hide_person": "ไม่สามารถซ่อนบุคคลได้", + "unable_to_load_album": "ไม่สามารถโหลดอัลบั้มได้", + "unable_to_load_asset_activity": "ไม่สามารถโหลดข้อมูลของสื่อได้", + "unable_to_load_items": "ไม่สามารถโหลดรายการได้", + "unable_to_load_liked_status": "ไม่สามารถโหลดสถานะ like ได้", + "unable_to_play_video": "ไม่สามารถเล่นวิดีโอได้", + "unable_to_refresh_user": "ไม่สามารถรีเฟรชผู้ใช้ได้", + "unable_to_remove_album_users": "ไม่สามารถลบผู้ใช้ออกจากอัลบั้มได้", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "ไม่สามารถลบคลังภาพได้", + "unable_to_remove_partner": "ไม่สามารถลบคู่หูได้", + "unable_to_remove_reaction": "ไม่สามารถลบ reaction ได้", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_user": "" + "unable_to_repair_items": "ไม่สามารถซ่อมแซมรายการได้", + "unable_to_reset_password": "ไม่สามารถตั้งรหัสผ่านใหม่ได้", + "unable_to_resolve_duplicate": "ไม่สามารถแก้ไขของซ้ำได้", + "unable_to_restore_assets": "ไม่สามารถเรียกคืนสื่อได้", + "unable_to_restore_trash": "ไม่สามารถเรียกคืนถังขยะได้", + "unable_to_restore_user": "ไม่สามารถเรียกคืนผู้ใช้ได้", + "unable_to_save_album": "ไม่สามารถบันทึกอัลบั้มได้", + "unable_to_save_name": "ไม่สามารถบันทึกชื่อได้", + "unable_to_save_profile": "ไม่สามารถบันทึกโปรไฟล์ได้", + "unable_to_save_settings": "ไม่สามารถบันทึกการตั้งค่าได้", + "unable_to_scan_libraries": "ไม่สามารถสแกนคลังภาพได้", + "unable_to_scan_library": "ไม่สามารถสแกนคลังภาพได้", + "unable_to_set_profile_picture": "ไม่สามารถตั้งภาพโปรไฟล์ได้", + "unable_to_submit_job": "ไม่สามารถส่งงานได้", + "unable_to_trash_asset": "ไม่สามารถทิ้งสื่อได้", + "unable_to_unlink_account": "ไม่สามารถยกเลิกการเชื่อมโยงบัญชีผู้ใช้ได้", + "unable_to_update_library": "ไม่สามารถอัพเดทคลังภาพได้", + "unable_to_update_location": "ไม่สามารถอัพเดทตําแหน่งได้", + "unable_to_update_settings": "ไม่สามารถอัพเดทการตั้งค่าได้", + "unable_to_update_user": "ไม่สามารถอัพเดทผู้ใช้ได้" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -528,20 +537,20 @@ "expired": "หมดอายุแล้ว", "explore": "สํารวจ", "extension": "ส่วนต่อขยาย", - "external_libraries": "", + "external_libraries": "ภายนอกคลังภาพ", "failed_to_get_people": "", "favorite": "รายการโปรด", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "โปรดหรือไม่โปรดภาพ", "favorites": "รายการโปรด", "feature": "", - "feature_photo_updated": "", + "feature_photo_updated": "อัพเดทภาพเด่นแล้ว", "featurecollection": "", "file_name": "", "file_name_or_extension": "", "filename": "ชื่อไฟล์", "files": "", "filetype": "ชนิดไฟล์", - "filter_people": "", + "filter_people": "กรองผู้คน", "fix_incorrect_match": "", "force_re-scan_library_files": "", "forward": "ไปข้างหน้า", @@ -553,124 +562,124 @@ "go_to_share_page": "", "group_albums_by": "", "has_quota": "", - "hide_gallery": "", + "hide_gallery": "ซ่อนคลังภาพ", "hide_password": "", - "hide_person": "", + "hide_person": "ซ่อนบุคคล", "host": "โฮสต์", "hour": "ชั่วโมง", "image": "รูปภาพ", "img": "", "immich_logo": "", - "import_path": "", - "in_archive": "", - "include_archived": "รวมเก็บถาวร", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", + "import_path": "นำเข้าพาธ", + "in_archive": "ในที่เก็บถาวร", + "include_archived": "รวมไฟล์เก็บถาวร", + "include_shared_albums": "รวมอัลบั้มที่แชร์กัน", + "include_shared_partner_assets": "รวมสื่อที่แชร์กับคู่หู", + "individual_share": "แชร์ส่วนตัว", "info": "ข้อมูล", "interval": { - "day_at_onepm": "", + "day_at_onepm": "ทุกวันเวลาบ่ายโมง", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "ทุกเที่ยงคืน", + "night_at_twoam": "ทุกวันเวลาตี 2" }, - "invite_people": "", + "invite_people": "เชิญผู้คน", "invite_to_album": "เชิญเข้าอัลบั้ม", "job_settings_description": "", "jobs": "งาน", "keep": "เก็บ", - "keyboard_shortcuts": "", + "keyboard_shortcuts": "ปุ่มพิมพ์ลัด", "language": "ภาษา", - "language_setting_description": "", - "last_seen": "", + "language_setting_description": "เลือกภาษาที่ต้องการ", + "last_seen": "เห็นล่าสุด", "leave": "ทิ้ง", "let_others_respond": "ให้คนอื่นตอบ", "level": "ระดับ", - "library": "คลัง", - "library_options": "", + "library": "คลังภาพ", + "library_options": "ตัวเลือกคลังภาพ", "light": "สว่าง", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", + "link_options": "ตัวเลือกลิงก์", + "link_to_oauth": "ลิงก์ไปยัง OAuth", + "linked_oauth_account": "ลิงก์บัญชีผู้ใช้ OAuth", "list": "รายการ", "loading": "กำลังโหลด", - "loading_search_results_failed": "", + "loading_search_results_failed": "โหลดผลการค้นหาล้มเหลว", "log_out": "ออกจากระบบ", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", + "log_out_all_devices": "ให้ทุกอุปกรณ์ออกจากระบบทั้งหมด", + "login_has_been_disabled": "ปิดการใช้งานการเข้าสู่ระบบแล้ว", + "look": "ดู", + "loop_videos": "วนวิดีโอ", "loop_videos_description": "เปิดเพื่อให้วิดีโอวนลูปในที่ดูรายละเอียด", - "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": "", + "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 ของคุณ", "map": "แผนที่", "map_marker_with_image": "", - "map_settings": "ตั้งค่าแผนที่", - "media_type": "", + "map_settings": "การตั้งค่าแผนที่", + "media_type": "ชนิดสื่อ", "memories": "ความทรงจำ", - "memories_setting_description": "", + "memories_setting_description": "จัดการสิ่งที่คุณเห็นในความทรงจําของคุณ", "menu": "เมนู", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", + "merge": "รวม", + "merge_people": "รวมผู้คน", + "merge_people_successfully": "รวมผู้คนเรียบร้อยแล้ว", "minimize": "ย่อลง", "minute": "นาที", "missing": "ขาดหาย", "model": "โมเดล", "month": "เดือน", "more": "เพิ่มเติม", - "moved_to_trash": "", - "my_albums": "", + "moved_to_trash": "ทิ้งลงถังขยะแล้ว", + "my_albums": "อัลบั้มของฉัน", "name": "ชื่อ", - "name_or_nickname": "", + "name_or_nickname": "ชื่อหรือชื่อเล่น", "never": "ไม่เคย", - "new_api_key": "", + "new_api_key": "กุญแจ API ใหม่", "new_password": "รหัสผ่านใหม่", - "new_person": "", - "new_user_created": "", - "newest_first": "", + "new_person": "คนใหม่", + "new_user_created": "สร้างผู้ใช้ใหม่แล้ว", + "newest_first": "ใหม่สุดก่อน", "next": "ต่อไป", "next_memory": "", "no": "ไม่", "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", + "no_archived_assets_message": "จัดเก็บรูปภาพและวีดิโอถาวรเพื่อซ่อนจากมุมมองคุณ", + "no_assets_message": "กดเพื่อใส่ภาพคุณภาพแรก", "no_exif_info_available": "", "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", + "no_favorites_message": "เพิ่มรายการโปรดเพื่อค้นหาภาพและวิดีโอที่ดีที่สุดของคุณอย่างรวดเร็ว", + "no_libraries_message": "สร้างคลังภาพภายนอกเพื่อดูภาพถ่ายและวิดีโอต่าง ๆ ของคุณ", + "no_name": "ไม่มีชื่อ", + "no_places": "ไม่มีสถานที่", + "no_results": "ไม่มีผลลัพธ์", + "no_shared_albums_message": "สร้างอัลบั้มเพื่อแชร์รูปภาพและวิดีโอกับคนในเครือข่ายของคุณ", + "not_in_any_album": "ไม่อยู่ในอัลบั้มใด ๆ", "notes": "หมายเหตุ", - "notification_toggle_setting_description": "", + "notification_toggle_setting_description": "เปิด/ปิด การแจ้งเตือนอีเมล", "notifications": "การแจ้งเตือน", - "notifications_setting_description": "", + "notifications_setting_description": "จัดการการแจ้งเตือน", "oauth": "OAuth", "offline": "ออฟไลน์", "ok": "โอเค", - "oldest_first": "", + "oldest_first": "เก่าสุดก่อน", "online": "ออนไลน์", - "only_favorites": "", + "only_favorites": "รายการโปรดเท่านั้น", "only_refreshes_modified_files": "", - "open_the_search_filters": "", + "open_the_search_filters": "เปิดตัวกรองการค้นหา", "options": "ตัวเลือก", - "organize_your_library": "", + "organize_your_library": "จัดเรียงคลังภาพของคุณ", "other": "อื่น ๆ", "other_devices": "", "other_variables": "", "owned": "เป็นเจ้าของ", "owner": "เจ้าของ", - "partner_sharing": "", - "partners": "", + "partner_sharing": "การแชร์แบบคู่หู", + "partners": "คู่หู", "password": "รหัสผ่าน", "password_does_not_match": "", "password_required": "", @@ -687,66 +696,66 @@ "paused": "หยุด", "pending": "กำลังรอ", "people": "ผู้คน", - "people_sidebar_description": "", + "people_sidebar_description": "แสดงลิงก์ไปยังผู้คนในแถบด้านข้าง", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", + "permanent_deletion_warning": "แจ้งเตือนการลบถาวร", + "permanent_deletion_warning_setting_description": "เตือนเมื่อจะลบสื่อถาวร", + "permanently_delete": "ลบถาวร", + "permanently_deleted_asset": "ลบสื่อถาวรแล้ว", "photos": "รูปภาพ", - "photos_from_previous_years": "", - "pick_a_location": "", + "photos_from_previous_years": "ภาพถ่ายจากปีก่อน", + "pick_a_location": "เลือกตําแหน่ง", "place": "สถานที่", "places": "สถานที่", "play": "เล่น", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", + "play_memories": "เล่นความทรงจํา", + "play_motion_photo": "เล่นภาพวัตถุเคลื่อนไหว", + "play_or_pause_video": "เล่นหรือหยุดวิดีโอ", "point": "", "port": "พอร์ต", "preset": "", "preview": "ตัวอย่าง", "previous": "ก่อนหน้า", - "previous_memory": "", - "previous_or_next_photo": "", + "previous_memory": "ความทรงจําก่อนหน้า", + "previous_or_next_photo": "ภาพก่อนหน้าหรือภาพถัดไป", "primary": "หลัก", - "profile_picture_set": "", - "public_share": "", + "profile_picture_set": "ตั้งภาพโปรไฟล์แล้ว", + "public_share": "แชร์แบบสาธารณะ", "range": "", "raw": "", - "reaction_options": "", - "read_changelog": "", + "reaction_options": "ตัวเลือก reaction", + "read_changelog": "อ่านบันทึกการเปลี่ยนแปลง", "recent": "ล่าสุด", - "recent_searches": "", + "recent_searches": "การค้นหาล่าสุด", "refresh": "รีเฟรช", - "refreshed": "ถูกรีเฟรช", - "refreshes_every_file": "", - "remove": "เอาออก", + "refreshed": "รีเฟรช", + "refreshes_every_file": "รีเฟรชทุกไฟล์", + "remove": "ลบ", + "remove_deleted_assets": "", "remove_from_album": "ลบออกจากอัลบั้ม", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "remove_offline_files": "", + "remove_from_favorites": "เอาออกจากรายการโปรด", + "remove_from_shared_link": "ลบออกจากลิงก์ที่แชร์", "repair": "ซ่อม", "repair_no_results_message": "", "replace_with_upload": "", - "require_password": "", + "require_password": "ต้องการรหัสผ่าน", "reset": "รีเซ็ต", - "reset_password": "", - "reset_people_visibility": "", + "reset_password": "ตั้งค่ารหัสผ่านใหม่", + "reset_people_visibility": "ปรับการมองเห็นใหม่", "reset_settings_to_default": "", - "restore": "กู้คืน", - "restore_user": "", - "retry_upload": "", + "restore": "เรียกคืน", + "restore_user": "เรียกคืนผู้ใช้", + "retry_upload": "ลองอัพโหลดใหม่", "review_duplicates": "", "role": "บทบาท", "save": "บันทึก", - "saved_profile": "", - "saved_settings": "", + "saved_profile": "โพรไฟล์ที่บันทึกไว้", + "saved_settings": "การตั้งค่าที่บันทึกไว้", "say_something": "พูดอะไรสักอย่าง", - "scan_all_libraries": "", + "scan_all_libraries": "สแกนคลังภาพทั้งหมด", "scan_all_library_files": "", "scan_new_library_files": "", - "scan_settings": "", + "scan_settings": "ตั้งค่าการสแกน", "search": "ค้นหา", "search_albums": "", "search_by_context": "", @@ -755,7 +764,7 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", + "search_people": "ค้นหาผู้คน", "search_places": "", "search_state": "", "search_timezone": "", @@ -767,11 +776,11 @@ "select_all": "", "select_avatar_color": "", "select_face": "", - "select_featured_photo": "", - "select_library_owner": "", + "select_featured_photo": "เลือกภาพเด่น", + "select_library_owner": "เลือกเจ้าของคลังภาพ", "select_new_face": "", "select_photos": "เลือกรูปภาพ", - "selected": "ถูกเลือก", + "selected": "เลือก", "send_message": "", "server": "เซิร์ฟเวอร์", "server_stats": "", @@ -782,76 +791,76 @@ "set_profile_picture": "", "set_slideshow_to_fullscreen": "", "settings": "ตั้งค่า", - "settings_saved": "", + "settings_saved": "บันทึกการตั้งค่าแล้ว", "share": "แชร์", "shared": "แชร์", - "shared_by": "", - "shared_by_you": "", + "shared_by": "แชร์โดย", + "shared_by_you": "แชร์โดยคุณ", "shared_links": "ลิงก์ที่แชร์", "sharing": "การแชร์", "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": "", + "show_album_options": "แสดงตัวเลือกอัลบั้ม", + "show_file_location": "แสดงตําแหน่งของไฟล์", + "show_gallery": "แสดงคลังภาพ", + "show_hidden_people": "แสดงคนที่ซ่อนไว้", + "show_in_timeline": "แสดงในไทม์ไลน์", + "show_in_timeline_setting_description": "แสดงรูปภาพและวิดีโอของผู้ใช้นี้ในไทม์ไลน์ของคุณ", + "show_keyboard_shortcuts": "แสดงปุ่มลัดแป้นพิมพ์", "show_metadata": "แสดง metadata", - "show_or_hide_info": "", - "show_password": "", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", + "show_or_hide_info": "แสดงหรือซ่อนข้อมูล", + "show_password": "แสดงรหัสผ่าน", + "show_person_options": "แสดงตัวเลือกของตัวบุคคล", + "show_progress_bar": "แสดงความคืบหน้า แถบ", + "show_search_options": "แสดงตัวเลือกการค้นหา", "shuffle": "สับเปลี่ยน", - "sign_up": "", + "sign_up": "ลงทะเบียน", "size": "ขนาด", - "skip_to_content": "", + "skip_to_content": "ข้ามไปยังเนื้อหา", "slideshow": "สไลด์", - "slideshow_settings": "", - "sort_albums_by": "", + "slideshow_settings": "ตั้งค่าสไลด์", + "sort_albums_by": "เรียงอัลบั้มโดย...", "stack": "ซ้อน", "stack_selected_photos": "", "stacktrace": "", - "start_date": "", + "start_date": "วันที่เริ่ม", "state": "รัฐ", "status": "สถานะ", - "stop_motion_photo": "", + "stop_motion_photo": "ภาพวัตถุเคลื่อนไหว", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", "storage": "ที่จัดเก็บ", - "storage_label": "", + "storage_label": "ฉลากจัดเก็บ", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", - "sunrise_on_the_beach": "", - "swap_merge_direction": "", + "sunrise_on_the_beach": "พระอาทิตย์ขึ้นบนชายหาด", + "swap_merge_direction": "สลับด้านรวม", "sync": "ซิงค์", "template": "แม่แบบ", "theme": "ธีม", - "theme_selection": "", - "theme_selection_description": "", - "time_based_memories": "", + "theme_selection": "การเลือกธีม", + "theme_selection_description": "ตั้งค่าธีมให้สว่างหรือมืดโดยอัตโนมัติ อิงจากค่าของเบราว์เซอร์ของคุณ", + "time_based_memories": "ความทรงจําตามเวลา", "timezone": "เขตเวลา", - "toggle_settings": "", - "toggle_theme": "", + "toggle_settings": "สลับการตั้งค่า", + "toggle_theme": "สลับธีม", "toggle_visibility": "", - "total_usage": "", + "total_usage": "การใช้งานรวม", "trash": "ขยะ", - "trash_all": "", - "trash_no_results_message": "", + "trash_all": "ทิ้งทั้งหมด", + "trash_no_results_message": "รูปและวีดีโอที่ถูกทิ้งจะมาโผล่ที่นี่", "type": "ประเภท", "unarchive": "นำออกจากที่เก็บถาวร", "unarchived": "", "unfavorite": "นำออกจากรายการโปรด", - "unhide_person": "", + "unhide_person": "ยกเลิกซ่อนบุคคล", "unknown": "ไม่ทราบ", "unknown_album": "", - "unknown_year": "", + "unknown_year": "ไม่ทราบปี", "unlink_oauth": "", "unlinked_oauth_account": "", - "unselect_all": "", + "unselect_all": "ยกเลิกการเลือกทั้งหมด", "unstack": "หยุดซ้อน", - "up_next": "", - "updated_password": "", + "up_next": "ต่อไป", + "updated_password": "รหัสผ่านเปลี่ยนแล้ว", "upload": "อัพโหลด", "upload_concurrency": "อัพโหลดพร้อมกัน", "url": "URL", @@ -864,7 +873,7 @@ "utilities": "", "validate": "ตรวจสอบ", "variables": "ตัวแปร", - "version": "เวอร์ชัน", + "version": "รุ่น", "video": "วิดีโอ", "video_hover_setting": "เล่นวิดีโอตัวอย่างเมื่อจ่อ", "video_hover_setting_description": "เล่นวิดีโอตัวอย่างเมื่อเมาส์จ่อข้างบน เมื่อปิดใช้งาน วิดีโอตัวอย่างยังสามารถเล่นได้โดยกดปุ่มเล่น", @@ -881,6 +890,6 @@ "welcome_to_immich": "ยินดีต้อนรับสู่ immich", "year": "ปี", "yes": "ใช่", - "you_dont_have_any_shared_links": "คุณไม่มีลิงก์ที่ใช้ร่วมกัน", + "you_dont_have_any_shared_links": "คุณไม่ได้มีลิงก์ที่แชร์", "zoom_image": "ซูมรูปภาพ" } diff --git a/web/src/lib/i18n/tr.json b/i18n/tr.json similarity index 64% rename from web/src/lib/i18n/tr.json rename to i18n/tr.json index 7981703b0f..b2f0559ce8 100644 --- a/web/src/lib/i18n/tr.json +++ b/i18n/tr.json @@ -10,11 +10,11 @@ "activity_changed": "Etkinlik {enabled, select, true {etkin} other {devre dışı}}", "add": "Ekle", "add_a_description": "Açıklama ekle", - "add_a_location": "Konum ekle", + "add_a_location": "Lokasyon ekle", "add_a_name": "İsim ekle", "add_a_title": "Başlık ekle", - "add_exclusion_pattern": "Dışlama deseni ekle", - "add_import_path": "İçeri aktarma yolu ekle", + "add_exclusion_pattern": "Hariç tutma deseni ekle", + "add_import_path": "İçe aktarma yolu ekle", "add_location": "Lokasyon ekle", "add_more_users": "Daha fazla kullanıcı ekle", "add_partner": "Partner ekle", @@ -25,69 +25,81 @@ "add_to_shared_album": "Paylaşılan albüme ekle", "added_to_archive": "Arşive eklendi", "added_to_favorites": "Favorilere eklendi", - "added_to_favorites_count": "{count} fotoğraf favorilere eklendi", + "added_to_favorites_count": "{count, number} fotoğraf favorilere eklendi", "admin": { - "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak globbing desteklenir. Herhangi bir \"Raw\" adlı dizindeki tüm dosyaları yoksaymak için \"**/Raw/**\" kullanın. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" kullanın. Mutlak yolu yoksaymak için \"/path/to/ignore/**\" kullanın.", - "authentication_settings": "Yetkilendirme ayarları", + "add_exclusion_pattern_description": "Dışlama desenleri ekleyin. *, ** ve ? kullanılarak Globbing (temsili yer doldurucu karakter) desteklenir. Farzedelim \"Raw\" adlı bir dizininiz var, içinde ki tüm dosyaları yoksaymak için \"**/Raw/**\" şeklinde yazabilirsiniz. \".tif\" ile biten tüm dosyaları yoksaymak için \"**/*.tif\" yazabilirsiniz. Mutlak yolu yoksaymak için \"/yoksayılacak/olan/yol/**\" şeklinde yazabilirsiniz.", + "asset_offline_description": "Bu harici kütüphane varlığı artık diskte bulunmuyor ve çöp kutusuna taşındı. Dosya kütüphane içinde taşındıysa, yeni karşılık gelen varlık için zaman çizelgenizi kontrol edin. Bu varlığı geri yüklemek için lütfen aşağıdaki dosya yolunun Immich tarafından erişilebilir olduğundan emin olun ve kütüphaneyi tarayın.", + "authentication_settings": "Yetkilendirme Ayarları", "authentication_settings_description": "Şifre, OAuth, ve diğer yetkilendirme ayarlarını yönet", "authentication_settings_disable_all": "Tüm giriş yöntemlerini devre dışı bırakmak istediğinize emin misiniz? Giriş yapma fonksiyonu tamamen devre dışı bırakılacak.", "authentication_settings_reenable": "Yeniden aktif etmek için Sunucu Komutu'nu kullanın.", - "background_task_job": "Arka plan görevleri", - "check_all": "Hepsini kontrol et", + "background_task_job": "Arka Plan Görevleri", + "check_all": "Hepsini Kontrol Et", "cleared_jobs": "{job} için işler temizlendi", - "config_set_by_file": "Ayarlar şuan için config dosyası tarafından ayarlandı", + "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?", - "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# contained asset} other {all # contained assets}} tane varlığı Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", + "confirm_delete_library_assets": "Bu kütüphaneyi silmek istediğinize emin misiniz? Bu işlem {count, plural, one {# tane varlığı} other {all # tane varlığı}} Immich'den silecek ve bu işlem geri alınamaz. Silinen dosyalar diskten silinmeyecek.", "confirm_email_below": "Onaylamak için aşağıya {email} yazın", "confirm_reprocess_all_faces": "Tüm yüzleri tekrardan işlemek istediğinize emin misiniz? Bu işlem isimlendirilmiş insanları da silecek.", "confirm_user_password_reset": "{user} adlı kullanıcının şifresini sıfırlamak istediğinize emin misiniz?", + "create_job": "Görev oluştur", "crontab_guru": "", "disable_login": "Girişi devre dışı bırak", "duplicate_detection_job_description": "Benzer fotoğrafları bulmak için makine öğrenmesini çalıştır. Bu işlem Akıllı Arama'ya bağlıdır", "exclusion_pattern_description": "Kütüphaneyi tararken dosya ve klasörleri görmezden gelmek için dışlama desenlerini kullanabilirsiniz. RAW dosyaları gibi bazı dosya ve klasörleri içe aktarmak istemediğinizde bu seçeneği kullanabilirsiniz.", "external_library_created_at": "Dış kütüphane ({date} tarihinde oluşturuldu.)", - "external_library_management": "Dış kütüphane yönetimi", + "external_library_management": "Dış Kütüphane Yönetimi", "face_detection": "Yüz tarama", - "face_detection_description": "Makine öğrenmesini kullanarak medyalardaki yüzleri bulun. Videolar için sadece önizleme görüntüleri kullanılacak. \"All\" tüm medyaları tekrardan işler. \"Missing\" daha önce işlenmemiş medyaları işlenmeleri için sıraya koyar. Tespit edilen yüzler yüz tarama işlemi tamamlandıktan sonra Yüz Tanıma için sıraya koyulacak ve kişiler olarak gruplandırılacak.", - "facial_recognition_job_description": "Tespit edilen yüzleri gruplandır. Bu işlem, yüz tanıma işlemi tamamlandıktan sonra çalışır. \"All\" tüm yüzleri gruplandırır. \"Missing\" ise tespit edilen fakat kişi atanmamış olan yüzleri sıraya koyar.", - "failed_job_command": "{job} işi için {command} komutu başarısız", - "force_delete_user_warning": "UYARI: Bu işlem kullanıcıyı ve bütün verilerini silecek. Bu işlem geri alınamaz ve silinen veriler geri kurtarılamaz.", - "forcing_refresh_library_files": "Tüm kütüphane dosyaları yenileniyor", + "face_detection_description": "Makine öğrenimi kullanarak varlıklardaki yüzleri tespit et. Videolar için sadece küçük resim (thumbnail) dikkate alınır. 'Yenile' tüm varlıkları yeniden işler. 'Sıfırla', mevcut tüm yüz verilerini temizleyerek işlemi yeniden başlatır. 'Eksik' henüz işlenmemiş varlıkları sıraya alır. Tespit edilen yüzler, Yüz Tanıma işlemi tamamlandıktan sonra mevcut ya da yeni kişilere gruplanmak üzere Yüz Tanıma için sıraya alınacaktır.", + "facial_recognition_job_description": "Algılanan yüzleri kişilere grupla. Bu adım, Yüz Tespit işlemi tamamlandıktan sonra çalışır. \"Sıfırla\", tüm yüzleri yeniden gruplandırır. \"Eksik\" ise henüz bir kişiye atanmamış yüzleri sıraya alır.", + "failed_job_command": "{job} görevi için {command} komutu başarısız", + "force_delete_user_warning": "UYARI: Bu işlem kullanıcıyı ve tüm varlıkları anında kaldıracaktır. Bu geri alınamaz ve dosyalar geri getirilemez.", + "forcing_refresh_library_files": "Tüm kütüphane dosyalarının zorunlu olarak yenilenmesi sağlanıyor", + "image_format": "Biçim", "image_format_description": "WebP, JPEG'e göre daha küçük dosya boyutu sunar fakat işlemesi daha uzun sürer.", "image_prefer_embedded_preview": "Gömülü önizlemeyi tercih et", "image_prefer_embedded_preview_setting_description": "RAW fotoğrafları için mümkün olduğunda gömülü önizlemeyi kullan. Bu, bazı fotoğraflarda daha gerçekçi renkler üretebilir, fakat önizlemenin kalitesi kullanılan kameraya bağlıdır ve fotoğrafta normalden daha fazla görüntü bozukluklarına sebep olabilir.", "image_prefer_wide_gamut": "Geniş renk aralığını tercih et", "image_prefer_wide_gamut_setting_description": "Önizleme görseli için P3 renk paletini tercih et. Bu, geniş renk paletli fotoğraflarda renk canlılığını daha iyi korur, fakat fotoğraflar eski tarayıcılarda ve eski cihazlarda daha farklı görünebilir. sRGB fotoğraflar renk paletini korumak için sRGB olarak tutulur.", + "image_preview_description": "Orta boyutlu görüntü, meta verisi çıkarılmış, tekil bir varlık görüntülenirken ve makine öğrenimi için kullanılır", "image_preview_format": "Biçimi önizle", + "image_preview_quality_description": "Ön izleme kalitesi 1-100 arasıdır. Yüksek değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir ve uygulama yanıt verme hızını düşürebilir. Düşük bir değer belirlemek, makine öğrenimi kalitesini etkileyebilir.", "image_preview_resolution": "Çözünürlüğü önizle", "image_preview_resolution_description": "Makine öğrenmesi ve tekil fotoğrafları görüntülerken kullanılır. Yüksek çözünürlük daha fazla detayı korur fakat işlemesi daha uzun sürer, daha fazla boyuta sahip olur ve uygulamanın performansını düşürebilir.", + "image_preview_title": "Ön izleme Ayarları", "image_quality": "Kalite", "image_quality_description": "Fotoğraf kalitesi 1-100. Yüksek değer, daha yüksek kalite demektir fakat boyutu arttırır. Bu özellik Önizlemeyi etkiler.", + "image_resolution": "Çözünürlük", + "image_resolution_description": "Daha yüksek çözünürlükle, daha fazla detayı koruyabilir ancak kodlanması daha uzun sürer, daha büyük dosya boyutlarına sahip olur ve uygulamanın yanıt verme hızını azaltabilir.", "image_settings": "Fotoğraf ayarları", "image_settings_description": "Oluşturulan fotoğrafların kalite ve çözünürlüklerini yönet", + "image_thumbnail_description": "Meta verisi çıkarılmış küçük boyutlu küçük resim, ana zaman çizelgesi gibi fotoğraf gruplarını görüntülerken kullanılır", "image_thumbnail_format": "Önizleme biçimi", + "image_thumbnail_quality_description": "Küçük resim kalitesi 1-100 arasında. Daha yüksek değerler daha iyidir, ancak daha büyük dosyalar üretir ve uygulamanın yanıt hızını azaltabilir.", "image_thumbnail_resolution": "Önizleme çözünürlüğü", "image_thumbnail_resolution_description": "Fotoğrafların grup hâlinde gösteriminde kullanılır (ana zaman akışı, albümler, vb.). Daha yüksek çözünürlükler daha fazla detayı koruyabilir ama kodlamaları daha uzun sürer, daha fazla yer kaplarlar, ve uygulamanın akıcılığını azaltabilirler.", - "job_concurrency": "{job} eşzamanlılık", + "image_thumbnail_title": "Küçük Fotoğraf Ayarları", + "job_concurrency": "{job} eş zamanlılık", + "job_created": "Görev oluşturuldu", "job_not_concurrency_safe": "Bu işlem eşzamanlama için uygun değil.", - "job_settings": "İş ayarları", - "job_settings_description": "Aynı anda çalışacak işleri yönet", - "job_status": "İş durumu", + "job_settings": "Görev Ayarları", + "job_settings_description": "Aynı anda çalışacak görevleri yönet", + "job_status": "Görev Statüleri", "jobs_delayed": "{jobCount, plural, other {# gecikmeli}}", "jobs_failed": "{jobCount, plural, other {# Başarısız}}", "library_created": "{library} kütüphanesi oluşturuldu", - "library_cron_expression": "Cron formatı", - "library_cron_expression_description": "Cron formatını kullanarak tarama aralığını belirleyin. Daha fazla bilgi için Crontab Guru", - "library_cron_expression_presets": "Cron formatı önayarları", + "library_cron_expression": "Cron zamanlaması", + "library_cron_expression_description": "Cron zamanlaması kullanarak tarama aralığını belirleyin. Daha fazla bilgi için Crontab Guru", + "library_cron_expression_presets": "Cron zamanlaması ön ayarları", "library_deleted": "Kütüphane silindi", - "library_import_path_description": "İçe aktarmak için bir klasör seçin. Bu klasör, alt klasörleriyle birlikte fotoğraf ve videolar için taranacak.", - "library_scanning": "Periyodik tarama", + "library_import_path_description": "Belirtilecek klasörü içe aktarın. Bu klasör, alt klasörler dahil olmak üzere, görüntüler ve videolar için taranacaktır.", + "library_scanning": "Periyodik Tarama", "library_scanning_description": "Periyodik kütüphane taramasını yönet", "library_scanning_enable_description": "Periyodik kütüphane taramasını etkinleştir", - "library_settings": "Dış kütüphane", - "library_settings_description": "Dış kütüphane ayarlarını yönet", + "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_watching_enable_description": "Dış kütüphaneleri dosya değişimi için izle", + "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", "logging_enable_description": "Günlüğü aktifleştir", @@ -112,7 +124,7 @@ "machine_learning_max_recognition_distance": "Maksimum tanıma uzaklığı", "machine_learning_max_recognition_distance_description": "İki suretin aynı kişi olarak kabul edildiği azami benzerlik oranı; 0-2 aralığında bir değerdir. Düşük değerler iki farklı kişinin sehven aynı kişi olarak algılanmasını engeller ama aynı kişinin farklı pozlarının farklı suretler olarak algılanmasına sebep olabilir. İki sureti birleştirmek daha kolay olduğu için mümkün olduğunca düşük değerler seçin.", "machine_learning_min_detection_score": "Minimum tespit skoru", - "machine_learning_min_detection_score_description": "Bir suretin algılanması için gerekli asgari kararlılık miktarı; 0-1 aralığında bir değerdir. Düşük değerler daha fazla suret tanır ama hatalı tanıma oranı artar.", + "machine_learning_min_detection_score_description": "Bir yüzün algılanması için gerekli asgari kararlılık miktarı; 0-1 aralığında bir değerdir. Düşük değerler daha fazla yüz tanır ama hatalı tanıma oranı artar.", "machine_learning_min_recognized_faces": "Minimum tanınan yüzler", "machine_learning_min_recognized_faces_description": "Kişi oluşturulması için gereken minimum yüzler. Bu değeri yükseltmek yüz tanıma doğruluğunu arttırır fakat yüzün bir kişiye atanmama olasılığını arttırır.", "machine_learning_settings": "Makine Öğrenmesi ayarları", @@ -128,25 +140,30 @@ "map_enable_description": "Harita ayarlarını etkinleştir", "map_gps_settings": "Harita & GPS Ayarları", "map_gps_settings_description": "Harita Yönetimi & GPS (Ters Jeokodlama) Ayarları", + "map_implications": "Harita özelliği, harici bir döşeme hizmetine (tiles.immich.cloud) bağlıdır", "map_light_style": "Açık mod", "map_manage_reverse_geocoding_settings": "Coğrafi Kodlama ayarlarını yönet", "map_reverse_geocoding": "Coğrafi Kodlama", "map_reverse_geocoding_enable_description": "Coğrafi Kodlamayı etkinleştir", "map_reverse_geocoding_settings": "Coğrafi Kodlama ayarları", - "map_settings": "Harita ayarları", + "map_settings": "Harita", "map_settings_description": "Harita ayarlarını yönet", "map_style_description": "style.json Harita ayarlarının URL'si", - "metadata_extraction_job": "Metadata'yı çıkart", - "metadata_extraction_job_description": "Tüm varlıklardan GPS, çözünürlük gibi metadatayı çıkart", + "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", + "metadata_faces_import_setting_description": "Yüzleri, EXIF verileri ve sidecar dosyalardan getir", + "metadata_settings": "Metaveri Ayarları", + "metadata_settings_description": "Metaveri ayarlarını yönet", "migration_job": "Birleştirme", - "migration_job_description": "Varlık önizlemelerini en yeni klasör yapısına aktar", + "migration_job_description": "Varlıklar ve yüzler için resim çerçeve önizlemelerini en yeni klasör yapısına aktar", "no_paths_added": "Yol eklenmedi", "no_pattern_added": "Desen eklenmedi", - "note_apply_storage_label_previous_assets": "", + "note_apply_storage_label_previous_assets": "Not: Daha önce yüklenen varlıklara Depolama Etiketi uygulamak için şu komutu çalıştırın", "note_cannot_be_changed_later": "NOT: Bu daha sonra değiştirilemez!", "note_unlimited_quota": "NOT: Sınırsız kota için 0 yazın", "notification_email_from_address": "Şu adresten", - "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", + "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", "notification_email_host_description": "E-posta sunucusunun ana bilgisayarı (örneğin, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Sertifika hatalarını görmezden gel", "notification_email_ignore_certificate_errors_description": "TLS sertifika doğrulama ayarlarını görmezden gel (Önerilmez)", @@ -172,7 +189,7 @@ "oauth_issuer_url": "Yayınlayıcı URL", "oauth_mobile_redirect_uri": "Mobil yönlendirme URL'si", "oauth_mobile_redirect_uri_override": "Mobilde zorla kullanılacak Yönlendirme Adresi", - "oauth_mobile_redirect_uri_override_description": "'app.immich:/' URL'si geçersiz olduğunda etkinleştir.", + "oauth_mobile_redirect_uri_override_description": "OAuth sağlayıcısı '{callback}'gibi bir mobil URI'ye izin vermediğinde etkinleştir.", "oauth_profile_signing_algorithm": "Profil imzalama algoritması", "oauth_profile_signing_algorithm_description": "Kullanıcının profilini imzalarken kullanılacak güvenlik algoritması.", "oauth_scope": "Kapsam", @@ -192,25 +209,28 @@ "password_settings": "Şifre giriş", "password_settings_description": "Şifre giriş ayarlarını yönet", "paths_validated_successfully": "Tüm yollar başarıyla doğrulandı", + "person_cleanup_job": "Kişi temizleme", "quota_size_gib": "Kota boyutu (GiB)", "refreshing_all_libraries": "Tüm kütüphaneler yenileniyor", "registration": "Yönetici kaydı", "registration_description": "Sistemdeki ilk kullanıcı olduğunuz için hesabınız Yönetici olarak ayarlandı. Yeni oluşturulan üyeliklerin, ve yönetici görevlerinin sorumlusu olarak atandınız.", - "removing_offline_files": "Çevrimdışı dosyalar kaldırılıyor", + "removing_deleted_files": "Çevrimdışı dosyalar kaldırılıyor", "repair_all": "Tümünü onar", - "repair_matched_items": "", + "repair_matched_items": "Eşleşen {sayı, çoğul, bir {# öğe} diğer {# öğeler}}", "repaired_items": "{count, plural, one {# item} other {# items}} tamir edildi", "require_password_change_on_login": "Kullanıcının ilk girişinde şifre değiştirmesini zorunlu kıl", "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", "scanning_library_for_changed_files": "Değişen dosyalar için kütüphane taranıyor", "scanning_library_for_new_files": "Yeni dosyalar için kütüphane taranıyor", - "send_welcome_email": "Hoşgeldin emaili yolla", + "search_jobs": "Görevleri 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", "server_settings": "Sunucu ayarları", "server_settings_description": "Sunucu ayarlarını yönet", - "server_welcome_message": "Hoşgeldin mesajı", + "server_welcome_message": "Hoş geldin mesajı", "server_welcome_message_description": "Giriş sayfasında gösterilen mesaj.", "sidecar_job": "Ek dosya ile taşınan metadata", "sidecar_job_description": "Ek dosyalardaki metadataları bul ve güncelle", @@ -232,6 +252,7 @@ "storage_template_settings_description": "Yüklenen dosyanın ismini ve klasör yapısını düzenle", "storage_template_user_label": "{label} kullanıcını dosyaları için kullanılan alt klasördür", "system_settings": "Sistem Ayarları", + "tag_cleanup_job": "Etiket temizleme", "theme_custom_css_settings": "Özel CSS", "theme_custom_css_settings_description": "CSS (Cascading Style Sheets) kullanılarak Immich'in tasarımı değiştirilebilir.", "theme_settings": "Tema ayarları", @@ -257,47 +278,47 @@ "transcoding_bitrate_description": "Videolar maksimum bir oranından yürksek ya da kabul edilir bir formatta değil", "transcoding_codecs_learn_more": "Buradaki terminolojiyi öğrenmek için FFmpeg dokümantasyonlarına bakabilirsiniz: H.264, HEVC ve VP9.", "transcoding_constant_quality_mode": "Sabit kalite modu", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", + "transcoding_constant_quality_mode_description": "ICQ, CQP'den daha iyidir, ancak bazı donanım hızlandırma cihazları bu modu desteklemez. Bu seçeneğin ayarlanması, kalite tabanlı kodlama kullanırken belirtilen modu tercih eder. ICQ'yu desteklemediği için NVENC tarafından göz ardı edilir.", + "transcoding_constant_rate_factor": "Sabit oran faktörü (-SOF)", + "transcoding_constant_rate_factor_description": "Video kalite seviyesi. Tipik değerler H.264 için 23, HEVC için 28, VP9 için 31 ve AV1 için 35'tir. Daha düşük değerler daha iyi kalite sağlar, ancak daha büyük dosyalar üretir.", + "transcoding_disabled_description": "Videoları dönüştürmeyin, bazı istemcilerde oynatma bozulabilir", "transcoding_hardware_acceleration": "Donanım Hızlandırma", "transcoding_hardware_acceleration_description": "Deneysel; daha hızlı, fakat aynı bitrate ayarlarında daha düşük kaliteye sahip", "transcoding_hardware_decoding": "Donanım çözücü", - "transcoding_hardware_decoding_setting_description": "Sadece NVENC, QSV ve RKMPP için geçerli. Sadece işlemeyi hızlandırmak yerine uçtan uca hızlandırmayı etkinleştirir. Tüm videolarda çalışmayabilir.", + "transcoding_hardware_decoding_setting_description": "Uçtan uca hızlandırmayı, sadece kodlamayı hızlandırmanın yerine etkinleştirir. Tüm videolarda çalışmayabilir.", "transcoding_hevc_codec": "HEVC kodek", "transcoding_max_b_frames": "Maksimum B-kareler", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames_description": "Daha yüksek değerler sıkıştırma verimliliğini artırır, ancak kodlamayı yavaşlatır. Eski cihazlarda donanım hızlandırma ile uyumlu olmayabilir. 0, B-çerçevelerini devre dışı bırakır, -1 ise bu değeri otomatik olarak ayarlar.", "transcoding_max_bitrate": "Maksimum bitrate", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", + "transcoding_max_bitrate_description": "Maksimum bit hızı ayarlamak, kaliteye küçük bir maliyetle dosya boyutlarını daha öngörülebilir hale getirebilir.", + "transcoding_max_keyframe_interval": "Maksimum ana kare aralığı", + "transcoding_max_keyframe_interval_description": "Ana kareler arasındaki maksimum kare mesafesini ayarlar. Düşük değerler sıkıştırma verimliliğini kötüleştirir, ancak arama sürelerini iyileştirir ve hızlı hareket içeren sahnelerde kaliteyi artırabilir. 0 bu değeri otomatik olarak ayarlar.", + "transcoding_optimal_description": "Hedef çözünürlükten yüksek veya kabul edilen formatta olmayan videolar", "transcoding_preferred_hardware_device": "Tercih edilen donanım cihazı", "transcoding_preferred_hardware_device_description": "Sadece VAAPI ve QSV için uygulanır. Donanım kod çevrimi için DRI Node ayarlar.", - "transcoding_preset_preset": "", + "transcoding_preset_preset": "Ön ayar (-ön)", "transcoding_preset_preset_description": "Sıkıştırma hızı. Daha yavaş olan ayarlar belirli bitrate ayarları için daha küçük ve daha kaliteli dosya üretir. VP9 ayarı 'daha hızlı' ayarının üstündeki ayarları görmezden gelir.", "transcoding_reference_frames": "Referans kareler", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", + "transcoding_reference_frames_description": "Belirli bir kareyi sıkıştırırken referans alınacak kare sayısı. Daha yüksek değerler sıkıştırma verimliliğini artırır, ancak kodlamayı yavaşlatır. 0 bu değeri otomatik olarak ayarlar.", + "transcoding_required_description": "Yalnızca kabul edilen formatta olmayan videolar", + "transcoding_settings": "Video Dönüştürme Ayarları", + "transcoding_settings_description": "Video dosyalarının çözünürlük ve kodlama bilgilerini yönetir", "transcoding_target_resolution": "Hedef çözünürlük", "transcoding_target_resolution_description": "Daha yüksek çözünürlükler daha fazla detayı koruyabilir fakat işlemesi daha uzun sürer, dosya boyutu daha yüksek olur ve uygulamanın akıcılığını etkileyebilir.", - "transcoding_temporal_aq": "", + "transcoding_temporal_aq": "Zamansal AQ", "transcoding_temporal_aq_description": "Sadece NVENC için geçerlidir. Yüksek-detayların ve düşük-hareket sahnelerin kalitesini arttır. Eski cihazlarla uyumlu olmayabilir.", "transcoding_threads": "İş Parçacıkları", - "transcoding_threads_description": "", + "transcoding_threads_description": "Daha yüksek değerler daha hızlı kodlamaya yol açar, ancak sunucunun etkin durumdayken diğer görevleri işlemesi için daha az alan bırakır. Bu değer İşlemci çekirdeği sayısından fazla olmamalıdır. 0'a ayarlanırsa kullanımı en üst düzeye çıkarır.", "transcoding_tone_mapping": "Ton-haritalama", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_tone_mapping_description": "HDR videoların SDR'ye dönüştürülürken görünümünü korumayı amaçlar. Her algoritma renk, detay ve parlaklık için farklı dengeleme yapar. Hable detayları korur, Mobius renkleri korur ve Reinhard parlaklığı korur.", + "transcoding_tone_mapping_npl": "Ton eşleme NPL", + "transcoding_tone_mapping_npl_description": "Renkler, bu parlaklıkta bir ekran için normal görünecek şekilde ayarlanacaktır. Karşıt olarak, daha düşük değerler videonun parlaklığını artırır ve tersi de geçerlidir çünkü ekranın parlaklığını telafi eder. 0 bu değeri otomatik olarak ayarlar.", + "transcoding_transcode_policy": "Dönüştürme (çevirme) politikası", + "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_description": "", + "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ı", "trash_number_of_days_description": "Varlıkların kalıcı olarak silinmeden önce çöpte kaç gün tutulacağı", @@ -305,31 +326,39 @@ "trash_settings_description": "Çöp ayarlarını yönet", "untracked_files": "İzlenmeyen dosyalar", "untracked_files_description": "Bu dosyalar uygulama tarafından izlenmiyor. Yarıda kesilen yüklemeler veya uygulama hatası bunlara sebep olmuş olabilir", + "user_cleanup_job": "Kullanıcı temizleme", "user_delete_delay": "{user} hesabı ve varlıkları {delay, plural, one {# day} other {# days}} gün içinde kalıcı olarak silinmek için planlandı.", "user_delete_delay_settings": "Silme gecikmesi", - "user_delete_delay_settings_description": "", - "user_delete_immediately_checkbox": "Kullanıcıyı ve tüm varlıklarını kalıcı olarak silmek için sıraya koy", + "user_delete_delay_settings_description": "Bir kullanıcının hesabını ve varlıklarını kalıcı olarak silmek için kaldırıldıktan sonra gereken gün sayısı. Kullanıcı silme işi, silinmeye hazır kullanıcıları kontrol etmek için gece yarısı çalışır. Bu ayardaki değişiklikler bir sonraki yürütmede değerlendirilecektir.", + "user_delete_immediately": "{user}'in hesabı ve varlıkları hemen kalıcı olarak silinmek üzere sıraya alınacak.", + "user_delete_immediately_checkbox": "Kullanıcı ve varlıkları hemen silinmek üzere sıraya al", "user_management": "Kullanıcı Yönetimi", "user_password_has_been_reset": "Kullanıcının şifresi sıfırlandı:", - "user_password_reset_description": "", + "user_password_reset_description": "Lütfen kullanıcıya geçici şifreyi sağlayın ve bir sonraki oturum açışında şifreyi değiştirmesi gerektiğini bildirin.", "user_restore_description": "{user} kullanıcısı geri yüklenecek.", + "user_restore_scheduled_removal": "Kullanıcıyı geri yükle - {date, date, long} tarihinde planlanan kaldırma", "user_settings": "Kullanıcı Ayarları", "user_settings_description": "Kullanıcı Ayarlarını Yönet", "user_successfully_removed": "Kullanıcı {email} başarıyla kaldırıldı.", - "version_check_enabled_description": "", + "version_check_enabled_description": "Sürüm kontrolü etkin", + "version_check_implications": "Sürüm kontrol özelliği, github.com ile periyodik iletişime dayanır", "version_check_settings": "Versiyon kontrolü", "version_check_settings_description": "Yeni sürüm bildirimini etkinleştir/devre dışı bırak", - "video_conversion_job": "", - "video_conversion_job_description": "" + "video_conversion_job": "Videoları dönüştür", + "video_conversion_job_description": "Tarayıcılar ve cihazlarla daha geniş uyumluluk için videoları dönüştür" }, "admin_email": "Yönetici Emaili", "admin_password": "Yönetici Şifresi", "administration": "Yönetim", "advanced": "Gelişmiş", + "age_months": "Yaş {months, plural, one {# ay} other {# ay}}", + "age_year_months": "1 yaş, {months, plural, one {# ay} other {# ay}}", + "age_years": "{years, plural, other {Yaş #}}", "album_added": "Albüm eklendi", "album_added_notification_setting_description": "Paylaşılan bir albüme eklendiğinizde email bildirimi alın", "album_cover_updated": "Albüm Kapağı güncellendi", - "album_delete_confirmation": "{album} albümünü silmek istediğinize emin misiniz?\nEğer bu albüm paylaşıma açıksa diğer kullanıcılar artık bu albüme erişemeyecek.", + "album_delete_confirmation": "{album} albümünü silmek istediğinize emin misiniz?", + "album_delete_confirmation_description": "Albüm paylaşılıyorsa, diğer kullanıcılar artık bu albüme erişemeyecektir.", "album_info_updated": "Albüm bilgisi güncellendi", "album_leave": "Albümden Ayrıl?", "album_leave_confirmation": "{album} albümünden ayrılmak istediğinize emin misiniz?", @@ -337,30 +366,35 @@ "album_options": "Albüm seçenekleri", "album_remove_user": "Kullanıcıyı kaldır?", "album_remove_user_confirmation": "{user} kullanıcısını kaldırmak istediğinize emin misiniz?", - "album_share_no_users": "Bu albümü tüm kullanıcılarla paylaşmışsınız ya da paylaşacak kullanıcı bulunmuyor.", + "album_share_no_users": "Görünüşe göre bu albümü tüm kullanıcılarla paylaştınız veya paylaşacak herhangi bir başka kullanıcınız yok.", "album_updated": "Albüm güncellendi", "album_updated_setting_description": "Paylaşılan bir albüme yeni bir varlık eklendiğinde email bildirimi alın", "album_user_left": "{album}den ayrıldınız", "album_user_removed": "{user} kaldırıldı", "album_with_link_access": "Link'e sahip olan herhangi bir kişinin bu albümdeki fotoğrafları ve kişileri görmesine izin ver.", "albums": "Albümler", - "albums_count": "", + "albums_count": "{count, plural, one {{count, number} Albüm} other {{count, number} Albüm}}", "all": "Tümü", "all_albums": "Tüm Albümler", "all_people": "Tüm Kişiler", "all_videos": "Tüm Videolar", "allow_dark_mode": "Koyu moda izin ver", "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ç", + "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.", "api_key_empty": "Apı Anahtarı isminiz boş olmamalı", "api_keys": "API Anahtarları", "app_settings": "Uygulama Ayarları", - "appears_in": "", + "appears_in": "Şurada görünür", "archive": "Arşiv", "archive_or_unarchive_photo": "Fotoğrafı arşivle/arşivden çıkar", "archive_size": "Arşiv boyutu", + "archive_size_description": "İndirmeler için arşiv boyutunu yapılandırın (GiB cinsinden)", "archived": "", + "archived_count": "{count, plural, other {# arşivlendi}}", "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", @@ -368,122 +402,164 @@ "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_offline": "Varlık çevrimdışı", + "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...", "assets": "Varlıklar", - "assets_restore_confirmation": "Çöpteki bütün varlıkları geri yüklemek istediğinize emin misiniz? Bu işlem geri alınamaz!", + "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", + "assets_added_to_name_count": "{count, plural, one {# varlık} other {# varlık}} {hasName, select, true {{name}} other {yeni albüm}} içine eklendi", + "assets_count": "{say, çoğul, bir {#varlık} diğer {#varlık}}", + "assets_moved_to_trash_count": "{say, çoğul, bir {#varlık} diğer {#varlık}} çöp kutusuna taşındı", + "assets_permanently_deleted_count": "Kalıcı olarak silindi {count, plural, one {# varlık} other {# varlıklar}}", + "assets_removed_count": "Kaldırıldı {count, plural, one {# varlık} other {# varlıklar}}", + "assets_restore_confirmation": "Tüm çöp kutusundaki varlıklarınızı geri yüklemek istediğinizden emin misiniz? Bu işlemi geri alamazsınız! Ayrıca, çevrim dışı olan varlıkların bu şekilde geri yüklenemeyeceğini unutmayın.", + "assets_restored_count": "{count, plural, one {# varlık} other {# varlıklar}} geri yüklendi", + "assets_trashed_count": "{count, plural, one {# varlık} other {# varlıklar}} çöp kutusuna taşındı", + "assets_were_part_of_album_count": "{count, plural, one {Varlık zaten} other {Varlıklar zaten}} albümün parçasıydı", "authorized_devices": "Yetki Verilmiş Cihazlar", "back": "Geri", - "back_close_deselect": "Geri, kapat, veya seçimi kaldır", - "backward": "", + "back_close_deselect": "Geri, kapat veya seçimi kaldır", + "backward": "Geriye doğru", "birthdate_saved": "Doğum günü başarılı bir şekilde kaydedildi", "birthdate_set_description": "Doğum günü, fotoğraftaki insanın fotoğraf çekildiği zamandaki yaşının hesaplanması için kullanılır.", "blurred_background": "Bulanık arkaplan", + "bugs_and_feature_requests": "Hatalar ve Özellik Talepleri", + "build": "Yapı", + "build_image": "Görüntü Oluştur", + "bulk_delete_duplicates_confirmation": "Toplu olarak {count, plural, one {# kopya öğeyi} other {# kopya öğeleri}} silmek istediğinizden emin misiniz? Bu işlem, her gruptaki en büyük öğeyi tutacak ve diğer tüm kopyaları kalıcı olarak silecektir. Bu işlemi geri alamazsınız!", + "bulk_keep_duplicates_confirmation": "{count, plural, one {# kopya öğeyi} other {# kopya öğeleri}} tutmak istediğinizden emin misiniz? Bu işlem, hiçbir şeyi silmeden tüm kopya gruplarını çözecektir.", + "bulk_trash_duplicates_confirmation": "{count, plural, one {# kopya öğeyi} other {# kopya öğeleri}} toplu olarak çöp kutusuna taşımak istediğinizden emin misiniz? Bu işlem, her grubun en büyük öğesini tutacak ve diğer tüm kopyaları çöp kutusuna taşıyacaktır.", + "buy": "Immich'i Satın Alın", "camera": "Kamera", "camera_brand": "Kamera markası", "camera_model": "Kamera modeli", "cancel": "İptal", "cancel_search": "Aramayı iptal et", - "cannot_merge_people": "", + "cannot_merge_people": "Kişiler birleştirilemiyor", "cannot_undo_this_action": "Bu işlem geri alınamaz!", - "cannot_update_the_description": "", + "cannot_update_the_description": "Açıklama güncellenemiyor", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", "change_date": "Tarihi değiştir", - "change_expiration_time": "", + "change_expiration_time": "Son kullanma süresini değiştir", "change_location": "Konumu değiştir", - "change_name": "İsmi değiştir", - "change_name_successfully": "", + "change_name": "İsim değiştir", + "change_name_successfully": "İsim başarıyla değiştirildi", "change_password": "Şifre Değiştir", "change_password_description": "Bu ya sistemdeki ilk oturum açışınız ya da şifre değişikliği için bir talepte bulunuldu. Lütfen yeni şifreyi aşağıya yazınız.", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", + "change_your_password": "Şifreni değiştir", + "changed_visibility_successfully": "Görünürlük başarıyla değiştirildi", + "check_all": "Tümünü Seç", + "check_logs": "Günlükleri Kontrol Et", "choose_matching_people_to_merge": "Birleştirmek için eşleşen kişileri seçiniz", "city": "Şehir", - "clear": "", + "clear": "Temiz", "clear_all": "Hepsini temizle", - "clear_message": "", - "clear_value": "", + "clear_all_recent_searches": "Son aramaların hepsini temizle", + "clear_message": "Mesajı Temizle", + "clear_value": "Değeri Temizle", + "clockwise": "Saat yönü", "close": "Kapat", - "collapse_all": "", + "collapse": "Daralt", + "collapse_all": "Tümünü Daralt", + "color": "Renk", "color_theme": "Renk teması", "comment_deleted": "Yorum silindi", - "comment_options": "", + "comment_options": "Yorum seçenekleri", "comments_and_likes": "Yorumlar & beğeniler", - "comments_are_disabled": "", + "comments_are_disabled": "Yorumlar devre dışı", "confirm": "Onayla", "confirm_admin_password": "Yönetici Şifresini Onayla", - "confirm_delete_shared_link": "", + "confirm_delete_shared_link": "Bu paylaşılan bağlantıyı silmek istediğinizden emin misiniz?", "confirm_password": "Şifreyi onayla", - "contain": "", - "context": "", - "continue": "", + "contain": "İçermek", + "context": "Bağlam", + "continue": "Devam et", "copied_image_to_clipboard": "Resim, panoya kopyalandı.", "copied_to_clipboard": "Panoya kopyalandı!", "copy_error": "Kopyalama hatası", - "copy_file_path": "", - "copy_image": "Resmi kopyala", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", + "copy_file_path": "Dosya yolunu kopyala", + "copy_image": "Resmi Kopyala", + "copy_link": "Bağlantıyı kopyala", + "copy_link_to_clipboard": "Bağlantıyı panoya kopyala", + "copy_password": "Parolayı kopyala", + "copy_to_clipboard": "Panoya Kopyala", "country": "Ülke", - "cover": "", - "covers": "", + "cover": "Kapla", + "covers": "Kaplar", "create": "Oluştur", "create_album": "Albüm oluştur", "create_library": "Kütüphane Oluştur", "create_link": "Link oluştur", "create_link_to_share": "Paylaşmak için link oluştur", + "create_link_to_share_description": "Bağlantıya sahip olan herkesin seçilen fotoğrafları görmesine izin ver", "create_new_person": "Yeni kişi oluştur", + "create_new_person_hint": "Seçili varlıkları yeni bir kişiye atayın", "create_new_user": "Yeni kullanıcı oluştur", + "create_tag": "Etiket oluştur", + "create_tag_description": "Yeni bir etiket oluşturun. İç içe geçmiş etiketler için, etiketi tam yolu ve eğik çizgileri de dahil ederek giriniz.", "create_user": "Kullanıcı oluştur", "created": "Oluşturuldu", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", + "current_device": "Mevcut cihaz", + "custom_locale": "Özel Yerel Ayar", + "custom_locale_description": "Tarihleri ve sayıları dile ve bölgeye göre biçimlendirin", + "dark": "Koyu", + "date_after": "Sonraki tarih", "date_and_time": "Tarih ve Zaman", - "date_before": "", + "date_before": "Önceki tarih", "date_of_birth_saved": "Doğum günü başarı ile kaydedildi", "date_range": "Tarih aralığı", "day": "Gün", - "default_locale": "", - "default_locale_description": "", + "deduplicate_all": "Tüm kopyaları kaldır", + "default_locale": "Varsayılan Yerel Ayar", + "default_locale_description": "Tarihleri ve sayıları tarayıcınızın yerel ayarına göre biçimlendirin", "delete": "Sil", "delete_album": "Albümü sil", - "delete_api_key_prompt": "", - "delete_key": "", + "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_key": "Anahtarı sil", "delete_library": "Kütüphaneyi sil", - "delete_link": "", + "delete_link": "Bağlantıyı sil", "delete_shared_link": "Paylaşılmış linki sil", + "delete_tag": "Etiketi sil", + "delete_tag_confirmation_prompt": "{tagName} etiketini silmek istediğinizden emin misiniz?", "delete_user": "Kullanıcıyı sil", - "deleted_shared_link": "", + "deleted_shared_link": "Paylaşılan bağlantı silindi", + "deletes_missing_assets": "Diskte eksik olan varlıkları siler", "description": "Açıklama", "details": "Detaylar", "direction": "Yön", - "disabled": "", - "disallow_edits": "", + "disabled": "Devre dışı bırakıldı", + "disallow_edits": "Değişikliklere izin verme", + "discord": "Discord", "discover": "Keşfet", "dismiss_all_errors": "Tüm hataları yoksay", "dismiss_error": "Hatayı yoksay", - "display_options": "", - "display_order": "", + "display_options": "Görüntüleme seçenekleri", + "display_order": "Gösterim sıralaması", "display_original_photos": "Orijinal fotoğrafları göster", - "display_original_photos_setting_description": "", + "display_original_photos_setting_description": "Orijinal varlık web uyumlu olduğunda, bir varlığı görüntülerken küçük resimler yerine orijinal fotoğrafı görüntülemeyi tercih edin. Bu, fotoğraf görüntüleme hızlarının yavaşlamasına neden olabilir.", "do_not_show_again": "Bu mesajı bir daha gösterme", - "done": "", + "documentation": "Dokümantasyon", + "done": "Bitti", "download": "İndir", - "downloading": "", - "duplicates": "", - "duration": "", + "download_include_embedded_motion_videos": "Gömülü videolar", + "download_include_embedded_motion_videos_description": "Görsel hareketli fotoğraflarda yer alan gömülü videoları ayrı bir dosya olarak dahil et", + "download_settings": "İndir", + "download_settings_description": "Varlık indirme ile ilgili ayarları yönetin", + "downloading": "İndiriliyor", + "downloading_asset_filename": "Varlık indiriliyor {filename}", + "drop_files_to_upload": "Dosyaları yüklemek için herhangi bir yere bırakın", + "duplicates": "Kopyalar", + "duplicates_description": "Her grubu çözmek için, varsa hangilerinin kopya olduğunu belirtin", + "duration": "Süre", "durations": { "days": "", "hours": "", @@ -491,66 +567,92 @@ "months": "", "years": "" }, - "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": "Düzenle", + "edit_album": "Albümü düzenle", + "edit_avatar": "Avatarı Düzenle", + "edit_date": "Tarihi Düzenle", + "edit_date_and_time": "Tarih ve zamanı düzenleyin", + "edit_exclusion_pattern": "Hariç tutma desenini düzenle", + "edit_faces": "Yüzleri Düzenleyin", + "edit_import_path": "İçe aktarma yolunu düzenleyin", + "edit_import_paths": "İçe Aktarma Yollarını Düzenle", + "edit_key": "Anahtarı düzenle", + "edit_link": "Bağlantıyı düzenle", + "edit_location": "Lokasyonu düzenleyin", + "edit_name": "İsmi düzenleyin", "edit_people": "Kişileri düzenle", + "edit_tag": "Etiketi düzenle", "edit_title": "Başlığı düzenle", - "edit_user": "", - "edited": "", - "editor": "", + "edit_user": "Kullanıcıyı düzenle", + "edited": "Düzenlendi", + "editor": "Editör", + "editor_close_without_save_prompt": "Değişiklikler kaydedilmeyecek", + "editor_close_without_save_title": "Düzenleyici kapatılsın mı?", + "editor_crop_tool_h2_aspect_ratios": "En boy oranları", + "editor_crop_tool_h2_rotation": "Rotasyon", "email": "E-posta", "empty_album": "", - "empty_trash": "", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "empty_trash": "Çöpü boşalt", + "empty_trash_confirmation": "Çöp kutusunu boşaltmak istediğinizden emin misiniz? Bu işlem, Immich'teki çöp kutusundaki tüm varlıkları kalıcı olarak silecektir.\nBu işlemi geri alamazsınız!", + "enable": "Etkinleştir", + "enabled": "Etkinleştirildi", + "end_date": "Bitiş tarihi", + "error": "Hata", + "error_loading_image": "Resim yüklenirken hata oluştu", + "error_title": "Bir Hata Oluştu - Bir şeyler ters gitti", "errors": { - "cleared_jobs": "", + "cannot_navigate_next_asset": "Sonraki varlığa geçiş yapılamıyor", + "cannot_navigate_previous_asset": "Önceki varlığa geçiş yapılamıyor", + "cant_apply_changes": "Değişiklikler uygulanamıyor", + "cant_change_activity": "Etkinliği {etkinleştiremiyor, seçemiyor, doğru {devre dışı bırakamıyor} diğer durumda {etkinleştiremiyor}}", + "cant_change_asset_favorite": "Varlığın favori durumunu değiştiremiyor", + "cant_change_metadata_assets_count": "{count} varlığın metadatası (meta verisi) değiştirilemiyor", + "cant_search_people": "Kişiler aranamıyor", + "cant_search_places": "Mekanlar aranamıyor", + "cleared_jobs": "İşler temizlendi: {job}", "exclusion_pattern_already_exists": "", "failed_job_command": "", + "failed_to_create_album": "Albüm oluşturulamadı", + "failed_to_create_shared_link": "Paylaşılan bağlantı oluşturulamadı", + "failed_to_edit_shared_link": "Paylaşılan bağlantı düzenlenemedi", + "failed_to_remove_product_key": "Ürün anahtarı kaldırılamadı", "import_path_already_exists": "", "incorrect_email_or_password": "Yanlış e-posta veya şifre", "paths_validation_failed": "", "profile_picture_transparent_pixels": "Profil resimleri şeffaf piksele sahip olamaz. Lütfen resme yakınlaştırın ve/veya resmi hareket ettirin.", "quota_higher_than_disk_size": "", "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", + "unable_to_add_album_users": "Kullanıcılar albüme eklenemiyor", + "unable_to_add_comment": "Yorum eklenemiyor", "unable_to_add_exclusion_pattern": "", "unable_to_add_import_path": "", "unable_to_add_partners": "", "unable_to_change_album_user_role": "", - "unable_to_change_date": "", + "unable_to_change_date": "Tarih değiştirilemiyor", "unable_to_change_location": "", "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", + "unable_to_connect": "Bağlanılamıyor", + "unable_to_connect_to_server": "Sunucuya bağlanılamıyor", + "unable_to_copy_to_clipboard": "Panoya kopyalanamıyor, sayfaya https üzerinden eriştiğinizden emin olun", + "unable_to_create_admin_account": "Yönetici hesabı oluşturulamıyor", + "unable_to_create_api_key": "Yeni API anahtarı oluşturulamıyor", "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", + "unable_to_create_user": "Kullanıcı oluşturulamıyor", + "unable_to_delete_album": "Albüm silinemiyor", "unable_to_delete_asset": "", "unable_to_delete_exclusion_pattern": "", "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", + "unable_to_delete_shared_link": "Paylaşılan bağlantı silinemiyor", + "unable_to_delete_user": "Kullanıcı silinemiyor", + "unable_to_download_files": "Dosyalar indirilemiyor", "unable_to_edit_exclusion_pattern": "", "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", + "unable_to_empty_trash": "Çöp boşaltılamıyor", + "unable_to_enter_fullscreen": "Tam ekran yapılamıyor", + "unable_to_exit_fullscreen": "Tam ekrandan çıkılamıyor", + "unable_to_get_comments_number": "Yorum sayısı alınamıyor", + "unable_to_get_shared_link": "Paylaşılan bağlantı alınamadı", + "unable_to_hide_person": "Kişi gizlenemiyor", "unable_to_link_oauth_account": "", "unable_to_load_album": "", "unable_to_load_asset_activity": "", @@ -560,8 +662,8 @@ "unable_to_refresh_user": "", "unable_to_remove_album_users": "", "unable_to_remove_api_key": "", + "unable_to_remove_deleted_assets": "", "unable_to_remove_library": "Kütüphane kaldırılamadı", - "unable_to_remove_offline_files": "", "unable_to_remove_partner": "", "unable_to_remove_reaction": "", "unable_to_repair_items": "Ögeler onarılamadı", @@ -743,13 +845,18 @@ "notification_toggle_setting_description": "E-posta bildirimlerine izin ver", "notifications": "Bildirimler", "notifications_setting_description": "Bildirimleri yönetin", - "oauth": "", - "offline": "Çevrimdışı", - "offline_paths": "", - "offline_paths_description": "", + "oauth": "OAuth", + "official_immich_resources": "Resmi Immich Kaynakları", + "offline": "Çevrim dışı", + "offline_paths": "Çevrim dışı yollar", + "offline_paths_description": "Bu sonuçlar, harici bir kütüphaneye ait olmayan dosyaların elle silinmesinden kaynaklanıyor olabilir.", "ok": "Tamam", - "oldest_first": "", - "onboarding_welcome_user": "Hoşgeldin, {user}", + "oldest_first": "Eski olan önce", + "onboarding": "Uyum Süreci", + "onboarding_privacy_description": "Şu (isteğe bağlı) özellikler harici hizmetlere dayanır ve yönetim ayarlarından herhangi bir zamanda devre dışı bırakılabilir.", + "onboarding_theme_description": "İnstance’ınız için bir renk teması seçin. Bunu daha sonra ayarlarınızdan değiştirebilirsiniz.", + "onboarding_welcome_description": "Şimdi, instance’ınızı bazı yaygın ayarlarla kurmaya başlayalım.", + "onboarding_welcome_user": "Hoş geldin, {user}", "online": "Çevrimiçi", "only_favorites": "Sadece favoriler", "only_refreshes_modified_files": "", @@ -819,10 +926,10 @@ "refreshed": "", "refreshes_every_file": "", "remove": "Kaldır", + "remove_deleted_assets": "", "remove_from_album": "", "remove_from_favorites": "", "remove_from_shared_link": "", - "remove_offline_files": "", "removed_api_key": "", "removed_from_favorites": "Favorilerden kaldırıldı", "rename": "Yeniden adlandır", @@ -1021,8 +1128,8 @@ "waiting": "Bekleniyor", "warning": "Uyarı", "week": "Hafta", - "welcome": "Hoşgeldiniz", - "welcome_to_immich": "Immich'e hoşgeldiniz", + "welcome": "Hoş geldiniz", + "welcome_to_immich": "Immich'e hoş geldiniz", "year": "Yıl", "yes": "Evet", "you_dont_have_any_shared_links": "", diff --git a/web/src/lib/i18n/uk.json b/i18n/uk.json similarity index 84% rename from web/src/lib/i18n/uk.json rename to i18n/uk.json index 33af6f13bb..3941576520 100644 --- a/web/src/lib/i18n/uk.json +++ b/i18n/uk.json @@ -1,7 +1,7 @@ { "about": "Про програму", "account": "Обліковий запис", - "account_settings": "Налаштування Облікового запису", + "account_settings": "Налаштування профілю", "acknowledge": "Прийняти", "action": "Дія", "actions": "Дії", @@ -25,9 +25,10 @@ "add_to_shared_album": "Додати у спільний альбом", "added_to_archive": "Додано до архіву", "added_to_favorites": "Додано до обраного", - "added_to_favorites_count": "Додано {count} до обраного", + "added_to_favorites_count": "Додано {count, number} до обраного", "admin": { "add_exclusion_pattern_description": "Додайте шаблони виключень. Підстановка з використанням *, ** та ? підтримується. Для ігнорування всіх файлів у будь-якому каталозі з ім'ям «Raw», використовуйте \"**/Raw/**\". Для ігнорування всіх файлів, що закінчуються на \".tif\", використовуйте \"**/*.tif\". Для ігнорування абсолютного шляху використовуйте \"/path/to/ignore/**\".", + "asset_offline_description": "Цей зовнішній бібліотечний актив більше не знайдено на диску і був переміщений до кошика. Якщо файл був переміщений у межах бібліотеки, перевірте свій таймлайн на наявність нового відповідного активу. Щоб відновити цей актив, переконайтеся, що шлях файлу нижче доступний для Immich, і проскануйте бібліотеку.", "authentication_settings": "Налаштування аутентифікації", "authentication_settings_description": "Управління паролями, OAuth та іншими налаштуваннями аутентифікації", "authentication_settings_disable_all": "Ви впевнені, що хочете вимкнути всі методи входу? Вхід буде повністю вимкнений.", @@ -41,35 +42,46 @@ "confirm_email_below": "Для підтвердження введіть \"{email}\" нижче", "confirm_reprocess_all_faces": "Ви впевнені, що хочете повторно визначити всі обличчя? Це також призведе до видалення імен з усіх облич.", "confirm_user_password_reset": "Ви впевнені, що хочете скинути пароль користувача {user}?", + "create_job": "Створити завдання", "crontab_guru": "", "disable_login": "Вимкнути вхід", "disabled": "", "duplicate_detection_job_description": "Запустити машинне навчання на активах для виявлення схожих зображень. Залежить від інтелектуального пошуку", "exclusion_pattern_description": "Шаблони виключень дозволяють ігнорувати файли та папки під час сканування вашої бібліотеки. Це корисно, якщо у вас є папки, які містять файли, які ви не хочете імпортувати, наприклад, RAW-файли.", "external_library_created_at": "Зовнішня бібліотека (створена {date})", - "external_library_management": "Управління Зовнішньою Бібліотекою", + "external_library_management": "Керування зовнішніми бібліотеками", "face_detection": "Виявлення обличчя", - "face_detection_description": "Виявлення обличчя на активах з використанням машинного навчання. Для відео розглядається лише ескіз. Опція \"Усі\" повторно обробляє всі активи. Опція \"Відсутні\" ставить в чергу активи, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для визначення обличчя після завершення виявлення обличчя, групуючи їх в існуючих або нових людей.", - "facial_recognition_job_description": "Групувати виявлені обличчя у людей. Цей крок виконується після завершення виявлення обличчя. Опція \"Усі\" перегруповує всі обличчя. Опція \"Відсутні\" ставить в чергу обличчя, які ще не мають призначеної особи.", + "face_detection_description": "Виявлення облич на медіафайлах за допомогою машинного навчання. Для відео обробляється лише ескіз. \"Оновити\" повторно обробляє всі файли. \"Скинути\" додатково очищає всі поточні дані про обличчя. \"Відсутні\" ставить у чергу файли, які ще не були оброблені. Виявлені обличчя будуть поставлені в чергу для розпізнавання після завершення виявлення, групуючи їх у вже існуючих або нових людей.", + "facial_recognition_job_description": "Групування виявлених облич у людей. Цей крок виконується після завершення виявлення облич. \"Скинути\" повторно кластеризує всі обличчя. \"Відсутні\" ставить у чергу обличчя, яким ще не призначено людину.", "failed_job_command": "Команда {command} не виконалася для завдання: {job}", "force_delete_user_warning": "ПОПЕРЕДЖЕННЯ: Це негайно призведе до видалення користувача і всіх активів. Цю дію не можна скасувати, і файли не можна буде відновити.", "forcing_refresh_library_files": "Примусове оновлення всіх файлів бібліотеки", + "image_format": "Формат", "image_format_description": "Формат WebP виробляє меньші файлів, ніж JPEG, але його кодування вимагає більше часу.", "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": "Для мініатюр використовуйте дисплей P3. Це краще зберігає яскравість зображень з широким колірним простором, але на старих пристроях зі старою версією браузера зображення можуть виглядати інакше. sRGB-зображення зберігаються у форматі sRGB, щоб уникнути зсуву кольорів.", + "image_preview_description": "Зображення середнього розміру з видаленими метаданими, яке використовується при перегляді одного об'єкта та для машинного навчання", "image_preview_format": "Формат прев'ю", + "image_preview_quality_description": "Якість попереднього перегляду від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Встановлення низького значення може вплинути на якість машинного навчання.", "image_preview_resolution": "Роздільність прев'ю", "image_preview_resolution_description": "Використовується при перегляді окремої фотографії та для машинного навчання. Вища роздільність може зберігати більше деталей, але потребує більше часу на кодування, має більший розмір файлу і може знижувати реакцію програми.", + "image_preview_title": "Налаштування попереднього перегляду", "image_quality": "Якість", "image_quality_description": "Якість зображення від 1 до 100. Чим вище, тим краще якість, але створюються більші файли, цей параметр впливає на прев'ю і мініатюри зображень.", + "image_resolution": "Роздільність", + "image_resolution_description": "Вища роздільність може зберігати більше деталей, але займає більше часу для кодування, має більші розміри файлів і може зменшити швидкість роботи програми.", "image_settings": "Налаштування зображення", "image_settings_description": "Керуйте якістю та роздільною здатністю згенерованих зображень", + "image_thumbnail_description": "Маленька мініатюра із видаленими метаданими, що використовується для перегляду груп фотографій, наприклад, на основній лінії часу", "image_thumbnail_format": "Формат ескізу", + "image_thumbnail_quality_description": "Якість мініатюри від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми.", "image_thumbnail_resolution": "Розмір ескізу", "image_thumbnail_resolution_description": "Використовується при перегляді груп фотографій (основна стрічка, перегляд альбому тощо). Вища роздільна здатність може зберегти більше деталей, але вимагає більше часу для кодування, має більший розмір файлів і може знижувати чутливість додатку.", + "image_thumbnail_title": "Налаштування мініатюр", "job_concurrency": "{job} одночасно", + "job_created": "Завдання створено", "job_not_concurrency_safe": "Це завдання не є безпечним для одночасного виконання.", "job_settings": "Налаштування завдань", "job_settings_description": "Управління паралельністю завдань", @@ -129,16 +141,21 @@ "map_enable_description": "Увімкнути функції мапи", "map_gps_settings": "Налаштування карти та GPS", "map_gps_settings_description": "Керування налаштуваннями карти та GPS (зворотний геокодинг)", + "map_implications": "Функція карти використовує зовнішній сервіс плиток (tiles.immich.cloud)", "map_light_style": "Світлий стиль", "map_manage_reverse_geocoding_settings": "Керувати налаштуваннями зворотного геокодування", "map_reverse_geocoding": "Зворотне геокодування", "map_reverse_geocoding_enable_description": "Увімкнути зворотне геокодування", "map_reverse_geocoding_settings": "Налаштування зворотного геокодування", - "map_settings": "Налаштування Мапи", + "map_settings": "Мапа", "map_settings_description": "Управління налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", "metadata_extraction_job": "Витягнути метадані", - "metadata_extraction_job_description": "Витягнення метаданих інформації з кожного ресурсу, таких як GPS-координати та роздільність", + "metadata_extraction_job_description": "Витягни метадані з кожного об'єкта, таку як GPS, обличчя та роздільна здатність", + "metadata_faces_import_setting": "Увімкни імпорт облич", + "metadata_faces_import_setting_description": "Імпортуй обличчя з EXIF-даних зображень та додаткових файлів", + "metadata_settings": "Налаштування метаданих", + "metadata_settings_description": "Керуй налаштуваннями метаданих", "migration_job": "Міграція", "migration_job_description": "Перемістіть мініатюри для ресурсів та обличчя до оновленої структури папок", "no_paths_added": "Шляхи не додано", @@ -147,7 +164,7 @@ "note_cannot_be_changed_later": "ПРИМІТКА: Це не можна змінити пізніше!", "note_unlimited_quota": "Примітка: Введіть 0 для необмеженого обсягу квоти", "notification_email_from_address": "З адреси", - "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", "notification_email_host_description": "Хост поштового сервера (наприклад, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ігнорувати помилки сертифіката", "notification_email_ignore_certificate_errors_description": "Ігнорувати помилки перевірки сертифікатів TLS (не рекомендується)", @@ -173,7 +190,7 @@ "oauth_issuer_url": "URL видачі", "oauth_mobile_redirect_uri": "URI мобільного перенаправлення", "oauth_mobile_redirect_uri_override": "Перевизначення URI мобільного перенаправлення", - "oauth_mobile_redirect_uri_override_description": "Увімкнути, якщо «app.immich:/» є недійсним URI перенаправлення.", + "oauth_mobile_redirect_uri_override_description": "Увімкнути, якщо OAuth-провайдер не підтримує мобільний URI, як '{callback}'", "oauth_profile_signing_algorithm": "Алгоритм підписання профілю", "oauth_profile_signing_algorithm_description": "Алгоритм, який використовується для підпису профілю користувача.", "oauth_scope": "Масштаб", @@ -193,23 +210,26 @@ "password_settings": "Налаштування входу з паролем", "password_settings_description": "Керування налаштуваннями входу за паролем", "paths_validated_successfully": "Усі шляхи успішно перевірено", + "person_cleanup_job": "Очищення особи", "quota_size_gib": "Розмір квоти (GiB)", "refreshing_all_libraries": "Оновлення всіх бібліотек", "registration": "Реєстрація адміністратора", "registration_description": "Оскільки ви перший користувач в системі, ви будете призначені Адміністратором і відповідатимете за адміністративні завдання, а додаткові користувачі будуть створені вами.", - "removing_offline_files": "Видалення недоступних файлів", + "removing_deleted_files": "Видалення недоступних файлів", "repair_all": "Відремонтуйте все", "repair_matched_items": "Відповідає {count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елементів}}", "repaired_items": "Відновлено {count, plural, one {# елемент} few {# елементи} many {# елементів} other {# елементів}}", "require_password_change_on_login": "Вимагати зміни пароля користувача при першому вході", "reset_settings_to_default": "Скинути налаштування до заводських значень", "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", + "scanning_library": "Сканування бібліотеки", "scanning_library_for_changed_files": "Сканування бібліотеки на наявність змінених файлів", "scanning_library_for_new_files": "Сканування бібліотеки на наявність нових файлів", + "search_jobs": "Пошук завдань...", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", - "server_settings": "Налаштування Серверу", + "server_settings": "Налаштування сервера", "server_settings_description": "Керування налаштуваннями сервера", "server_welcome_message": "Вітальне повідомлення", "server_welcome_message_description": "Повідомлення, яке відображається на сторінці входу.", @@ -233,13 +253,14 @@ "storage_template_settings_description": "Керуйте структурою тек та іменем завантаженого файлу", "storage_template_user_label": "{label} - це мітка зберігання користувача", "system_settings": "Системні налаштування", + "tag_cleanup_job": "Очистити тег", "theme_custom_css_settings": "Власний CSS", "theme_custom_css_settings_description": "Каскадні таблиці стилів дозволяють настроювати дизайн Immich.", "theme_settings": "Налаштування теми", "theme_settings_description": "Налаштування персоналізації веб-інтерфейсу Immich", "these_files_matched_by_checksum": "Ці файли відповідають своїм контрольним сумам", "thumbnail_generation_job": "Створення мініатюр", - "thumbnail_generation_job_description": "Створити великі, малі та розмиті ескізи для кожного ресурсу, а також ескізи для кожної особи", + "thumbnail_generation_job_description": "Створити великі, малі та розмиті мініатюри для кожного ресурсу, а також мініатюри для кожної особи", "transcode_policy_description": "", "transcoding_acceleration_api": "API прискорення", "transcoding_acceleration_api_description": "API, яка буде взаємодіяти з вашим пристроєм для прискорення транскодування. Ця настройка працює у \"найкращих умовах\" і, в разі невдачі, перейде на програмне транскодування. Підтримка VP9 може або не може працювати, залежно від вашого обладнання.", @@ -266,7 +287,7 @@ "transcoding_hardware_acceleration": "Апаратне прискорення", "transcoding_hardware_acceleration_description": "Експериментальний режим: значно швидший, але при однаковому бітрейті може мати меншу якість", "transcoding_hardware_decoding": "Апаратне декодування", - "transcoding_hardware_decoding_setting_description": "Застосовується тільки до NVENC, QSV і RKMPP. Вмикає наскрізне прискорення на заміну лише прискорення кодування. Може працювати не з усіма відео.", + "transcoding_hardware_decoding_setting_description": "Увімкнення наскрізного прискорення замість прискорення лише кодування. Може не працювати для всіх відео.", "transcoding_hevc_codec": "Кодек HEVC", "transcoding_max_b_frames": "Максимальна кількість проміжних кадрів", "transcoding_max_b_frames_description": "Вищі значення покращують ефективність стиснення, але збільшують час кодування. Можуть бути несумісні з апаратним прискоренням на старих пристроях. Значення 0 вимикає B-фрейми, а -1 автоматично налаштовує це значення.", @@ -278,11 +299,11 @@ "transcoding_preferred_hardware_device": "Переважний апаратний пристрій", "transcoding_preferred_hardware_device_description": "Застосовується тільки до VAAPI і QSV. Встановлює вузол DRI, який використовується для апаратного транскодування.", "transcoding_preset_preset": "Параметр (-preset)", - "transcoding_preset_preset_description": "Швидкість стиснення. Повільніше предустановки створюють менші файли і підвищують якість при встановленні певного бітрейту. VP9 ігнорує швидкості вище `faster`.", + "transcoding_preset_preset_description": "Швидкість стиснення. Повільніші пресети створюють менші файли і підвищують якість при певному бітрейті. VP9 ігнорує швидкості вище 'швидше'.", "transcoding_reference_frames": "Основні кадри", "transcoding_reference_frames_description": "Кількість кадрів, на які посилається при стисненні даного кадру. Вищі значення покращують ефективність стиснення, але збільшують час кодування. Значення 0 автоматично налаштовує це значення.", "transcoding_required_description": "Лише відео, що не у прийнятому форматі", - "transcoding_settings": "Налаштування Транскодування Відео", + "transcoding_settings": "Налаштування транскодування відео", "transcoding_settings_description": "Керування роздільною здатністю та кодуванням відеофайлів", "transcoding_target_resolution": "Роздільна здатність", "transcoding_target_resolution_description": "Вищі роздільні здатності можуть зберігати більше деталей, але займають більше часу на кодування, мають більші розміри файлів і можуть зменшити швидкість роботи додатку.", @@ -307,12 +328,13 @@ "trash_settings_description": "Керування налаштуваннями кошика", "untracked_files": "Невідстежувані файли", "untracked_files_description": "Ці файли не відстежуються програмою. Вони можуть бути результатом невдалого переміщення, перерваного завантаження або залишитися через помилку програми", + "user_cleanup_job": "Очищення користувача", "user_delete_delay": "Акаунт {user} і його ресурси будуть заплановані для остаточного видалення через {delay, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", "user_delete_delay_settings": "Видалити затримку", "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", "user_delete_immediately": "Акаунт та ресурси користувача {user} будуть негайно поставлені в чергу на остаточне видалення.", "user_delete_immediately_checkbox": "Поставити користувача та ресурси в чергу для негайного видалення", - "user_management": "Управління користувачами", + "user_management": "Керування користувачами", "user_password_has_been_reset": "Пароль користувача було скинуто:", "user_password_reset_description": "Будь ласка, надайте користувачеві тимчасовий пароль і повідомте йому, що він повинен буде змінити пароль при наступному вході.", "user_restore_description": "Акаунт {user} буде відновлено.", @@ -320,7 +342,8 @@ "user_settings": "Налаштування користувача", "user_settings_description": "Керування налаштуваннями користувачів", "user_successfully_removed": "Користувача з електронною поштою {email} успішно видалено.", - "version_check_enabled_description": "Увімкнення періодичних запитів до GitHub для перевірки нових випусків", + "version_check_enabled_description": "Увімкнути перевірку версії", + "version_check_implications": "Функція перевірки версії залежить від періодичної комунікації з github.com", "version_check_settings": "Перевірка версії", "version_check_settings_description": "Увімкнути/вимкнути сповіщення про нову версію", "video_conversion_job": "Перекодувати відео", @@ -336,7 +359,8 @@ "album_added": "Альбом додано", "album_added_notification_setting_description": "Отримувати повідомлення по електронній пошті, коли вас додають до спільного альбому", "album_cover_updated": "Обкладинка альбому оновлена", - "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?\nЯкщо цей альбом є спільним, інші користувачі більше не зможуть отримувати до нього доступ.", + "album_delete_confirmation": "Ви впевнені, що хочете видалити альбом {album}?", + "album_delete_confirmation_description": "Якщо альбом був спільним, інші користувачі не зможуть отримати доступ до нього.", "album_info_updated": "Інформація про альбом оновлена", "album_leave": "Залишити альбом?", "album_leave_confirmation": "Ви впевнені, що хочете залишити альбом {album}?", @@ -360,6 +384,7 @@ "allow_edits": "Дозволити редагування", "allow_public_user_to_download": "Дозволити публічному користувачеві завантажувати файли", "allow_public_user_to_upload": "Дозволити публічним користувачам завантажувати", + "anti_clockwise": "Проти годинникової стрілки", "api_key": "Ключ API", "api_key_description": "Це значення буде показане лише один раз. Будь ласка, обов'язково скопіюйте його перед закриттям вікна.", "api_key_empty": "Назва вашого ключа API не може бути порожньою", @@ -381,8 +406,9 @@ "asset_has_unassigned_faces": "Є нерозпізнані обличчя", "asset_hashing": "Хешування...", "asset_offline": "Актив вимкнено", - "asset_offline_description": "Цей ресурс відключений. Immich не може отримати доступ до його місцезнаходження файлів. Будь ласка, переконайтеся, що ресурс доступний, а потім знову проскануйте бібліотеку.", + "asset_offline_description": "Цей зовнішній актив більше не знайдено на диску. Будь ласка, зверніться до адміністратора Immich за допомогою.", "asset_skipped": "Пропущено", + "asset_skipped_in_trash": "У смітнику", "asset_uploaded": "Завантажено", "asset_uploading": "Завантаження...", "assets": "елементи", @@ -393,7 +419,7 @@ "assets_moved_to_trash_count": "Переміщено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}} у кошик", "assets_permanently_deleted_count": "Остаточно видалено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_removed_count": "Вилучено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", - "assets_restore_confirmation": "Ви впевнені, що хочете відновити всі видалені ресурси? Цю дію не можна скасувати!", + "assets_restore_confirmation": "Ви впевнені, що хочете відновити всі свої активи з кошика? Цю дію не можна скасувати! Зверніть увагу, що будь-які офлайн-активи не можуть бути відновлені таким чином.", "assets_restored_count": "Відновлено {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_trashed_count": "Поміщено в кошик {count, plural, one {# ресурс} few {# ресурси} other {# ресурсів}}", "assets_were_part_of_album_count": "{count, plural, one {Ресурс був} few {Ресурси були} other {Ресурси були}} вже частиною альбому", @@ -404,12 +430,13 @@ "birthdate_saved": "Дата народження успішно збережена", "birthdate_set_description": "Дата народження використовується для обчислення віку цієї особи на момент фотографії.", "blurred_background": "Розмитий фон", + "bugs_and_feature_requests": "Помилки та запити на функції", "build": "Збірка", "build_image": "Створити зображення", "bulk_delete_duplicates_confirmation": "Ви впевнені, що хочете масово видалити {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}}? Це дія залишить найбільший ресурс у кожній групі і остаточно видалить всі інші дублікати. Цю дію неможливо скасувати!", "bulk_keep_duplicates_confirmation": "Ви впевнені, що хочете залишити {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}}? Це дозволить вирішити всі групи дублікатів без видалення чого-небудь.", "bulk_trash_duplicates_confirmation": "Ви впевнені, що хочете викинути в кошик {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} other {# дубльованих ресурсів}} масово? Це залишить найбільший ресурс у кожній групі і викине в кошик всі інші дублікати.", - "buy": "Ліцензія на придбання", + "buy": "Придбайте Immich", "camera": "Камера", "camera_brand": "Марка камери", "camera_model": "Модель камери", @@ -440,9 +467,11 @@ "clear_all_recent_searches": "Очистити всі останні пошукові запити", "clear_message": "Очистити повідомлення", "clear_value": "Очистити значення", + "clockwise": "По годинниковій стрілці", "close": "Закрити", "collapse": "Згорнути", "collapse_all": "Згорнути все", + "color": "Колір", "color_theme": "Кольорова тема", "comment_deleted": "Коментар видалено", "comment_options": "Параметри коментарів", @@ -476,6 +505,8 @@ "create_new_person": "Створити нову особу", "create_new_person_hint": "Призначити обраним активам нову особу", "create_new_user": "Створити нового користувача", + "create_tag": "Створити тег", + "create_tag_description": "Створити новий тег. Для вкладених тегів вкажіть повний шлях тега, включаючи слеші.", "create_user": "Створити користувача", "created": "Створено", "current_device": "Поточний пристрій", @@ -499,13 +530,17 @@ "delete_library": "Видалити бібліотеку", "delete_link": "Видалити посилання", "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": "Пропустити помилку", @@ -514,8 +549,11 @@ "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": "Скачування", @@ -545,10 +583,15 @@ "edit_location": "Редагувати місцезнаходження", "edit_name": "Відредагувати ім'я", "edit_people": "Редагувати людей", + "edit_tag": "Редагувати тег", "edit_title": "Редагувати заголовок", "edit_user": "Редагувати користувача", "edited": "Відредаговано", - "editor": "", + "editor": "Редактор", + "editor_close_without_save_prompt": "Зміни не будуть збережені", + "editor_close_without_save_title": "Закрити редактор?", + "editor_crop_tool_h2_aspect_ratios": "Пропорції зображення", + "editor_crop_tool_h2_rotation": "Орієнтація", "email": "Електронна пошта", "empty": "", "empty_album": "", @@ -588,6 +631,7 @@ "failed_to_load_asset": "Не вдалося завантажити ресурс", "failed_to_load_assets": "Не вдалося завантажити ресурси", "failed_to_load_people": "Не вдалося завантажити людей", + "failed_to_remove_product_key": "Не вдалося видалити ключ продукту", "failed_to_stack_assets": "Не вдалося згорнути ресурси", "failed_to_unstack_assets": "Не вдалося розгорнути ресурси", "import_path_already_exists": "Цей шлях імпорту вже існує.", @@ -637,6 +681,7 @@ "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": "Неможливо завантажити активність активу", @@ -653,8 +698,8 @@ "unable_to_remove_api_key": "Не вдається видалити ключ API", "unable_to_remove_assets_from_shared_link": "Не вдається видалити ресурси зі спільного посилання", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Неможливо видалити автономні файли", "unable_to_remove_library": "Не вдається видалити бібліотеку", - "unable_to_remove_offline_files": "Неможливо видалити автономні файли", "unable_to_remove_partner": "Не вдається видалити партнера", "unable_to_remove_reaction": "Не вдалося видалити реакцію", "unable_to_remove_user": "", @@ -677,6 +722,7 @@ "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": "Не вдалося оновити бібліотеку", @@ -697,6 +743,7 @@ "expired": "Закінчився термін дії", "expires_date": "Термін дії закінчується {date}", "explore": "Дослідити", + "explorer": "Провідник", "export": "Експортувати", "export_as_json": "Експорт в JSON", "extension": "Розширення", @@ -710,6 +757,8 @@ "feature": "", "feature_photo_updated": "Вибране фото оновлено", "featurecollection": "", + "features": "Додаткові можливості", + "features_setting_description": "Керування додатковими можливостями додатка", "file_name": "Ім'я файлу", "file_name_or_extension": "Ім'я файлу або розширення", "filename": "Ім'я файлу", @@ -718,6 +767,8 @@ "filter_people": "Фільтр по людях", "find_them_fast": "Швидко знаходьте їх за назвою за допомогою пошуку", "fix_incorrect_match": "Виправити неправильний збіг", + "folders": "Папки", + "folders_feature_description": "Перегляд перегляду папок для фотографій і відео у файловій системі", "force_re-scan_library_files": "Примусово пересканувати всі файли бібліотеки", "forward": "Переслати", "general": "Загальні", @@ -741,7 +792,16 @@ "host": "Хост", "hour": "Година", "image": "Зображення", - "image_alt_text_date": "{date}", + "image_alt_text_date": "{isVideo, select, true {Відео} other {Зображення}} знято {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Відео} other {Зображення}} з {person1} зроблено {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1} та {person2} зроблено {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1}, {person2} і {person3} зроблено {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Відео} other {Зображення}} з {person1}, {person2} та ще {additionalCount, number} особами зроблено {date}", + "image_alt_text_date_place": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1} та {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1}, {person2} та {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Відео} other {Зображення}} зроблено в {city}, {country} з {person1}, {person2} та ще {additionalCount, number} особами {date}", "image_alt_text_people": "{count, plural, =1 {з {person1}} =2 {з {person1} та {person2}} =3 {з {person1}, {person2}, та {person3}} other {з {person1}, {person2}, та {others, number} ін.}}", "image_alt_text_place": "у {city}, {country}", "image_taken": "{isVideo, select, true {Зняте відео} other {Зроблений знімок}}", @@ -808,6 +868,7 @@ "license_trial_info_4": "Будь ласка, розгляньте можливість придбання ліцензії для підтримки подальшого розвитку сервісу", "light": "Світла", "like_deleted": "Лайк видалено", + "link_motion_video": "Посилання на рухоме відео", "link_options": "Налаштування посилання", "link_to_oauth": "Приєднання до OAuth", "linked_oauth_account": "Приєднаний акаунт OAuth", @@ -826,6 +887,7 @@ "look": "Дивитися", "loop_videos": "Циклічні відео", "loop_videos_description": "Увімкнути циклічне відтворення відео.", + "main_branch_warning": "Ви використовуєте версію для розробників; ми настійно рекомендуємо використовувати релізну версію!", "make": "Виробник", "manage_shared_links": "Керування спільними посиланнями", "manage_sharing_with_partners": "Керуйте спільним використанням з партнерами", @@ -862,6 +924,7 @@ "name": "Ім'я", "name_or_nickname": "Ім'я або псевдонім", "never": "ніколи", + "new_album": "Новий альбом", "new_api_key": "Новий ключ API", "new_password": "Новий пароль", "new_person": "Нова людина", @@ -894,18 +957,21 @@ "notifications": "Сповіщення", "notifications_setting_description": "Керування сповіщеннями", "oauth": "OAuth", + "official_immich_resources": "Офіційні ресурси Immich", "offline": "Офлайн", "offline_paths": "Недоступні шляхи", "offline_paths_description": "Ці результати можуть бути пов'язані з ручним видаленням файлів, які не є частиною зовнішньої бібліотеки.", "ok": "ОК", "oldest_first": "Спочатку найстарші", "onboarding": "Введення", + "onboarding_privacy_description": "Наступні (необов'язкові) функції залежать від зовнішніх сервісів і можуть бути вимкнені в будь-який час у налаштуваннях адміністрації.", "onboarding_theme_description": "Виберіть колірну тему для свого екземпляра. Ви можете змінити її пізніше в налаштуваннях.", "onboarding_welcome_description": "Давайте налаштуємо ваш екземпляр за допомогою деяких загальних параметрів.", "onboarding_welcome_user": "Ласкаво просимо, {user}", "online": "Доступний", "only_favorites": "Лише обрані", "only_refreshes_modified_files": "Оновлює лише змінені файли", + "open_in_map_view": "Відкрити у перегляді мапи", "open_in_openstreetmap": "Відкрити в OpenStreetMap", "open_the_search_filters": "Відкрийте фільтри пошуку", "options": "Налаштування", @@ -915,7 +981,7 @@ "other": "Інше", "other_devices": "Інші пристрої", "other_variables": "Інші змінні", - "owned": "У власності", + "owned": "Власні", "owner": "Власник", "partner": "Партнер", "partner_can_access": "{partner} має доступ", @@ -940,6 +1006,7 @@ "pending": "На розгляді", "people": "Люди", "people_edits_count": "Відредаговано {count, plural, one {# особу} few {# особи} many {# осіб} other {# людей}}", + "people_feature_description": "Перегляд фотографій і відео, згрупованих за людьми", "people_sidebar_description": "Відображення посилання на людей у бічній панелі", "perform_library_tasks": "", "permanent_deletion_warning": "Попередження про видалення", @@ -971,11 +1038,48 @@ "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 та програмного забезпечення з відкритим кодом", + "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": "Ключ продукту сервера керується адміністратором", "range": "", + "rating": "Зоряний рейтинг", + "rating_clear": "Очистити рейтинг", + "rating_count": "{count, plural, one {# зірка} few {# зірки} many {# зірок} other {# зірок}}", + "rating_description": "Показувати рейтинг EXIF на інформаційній панелі", "raw": "", "reaction_options": "Опції реакції", "read_changelog": "Прочитати зміни в оновленні", @@ -987,11 +1091,13 @@ "recent_searches": "Нещодавні пошукові запити", "refresh": "Оновити", "refresh_encoded_videos": "Оновити закодовані відео", + "refresh_faces": "Оновити обличчя", "refresh_metadata": "Оновити метадані", "refresh_thumbnails": "Оновити мініатюри", "refreshed": "Оновлений", - "refreshes_every_file": "Оновлює кожен файл", + "refreshes_every_file": "Повторно читає всі існуючі та нові файли", "refreshing_encoded_video": "Оновлення закодованого відео", + "refreshing_faces": "Оновлення облич", "refreshing_metadata": "Оновлення метаданих", "regenerating_thumbnails": "Відновлення мініатюр", "remove": "Вилучити", @@ -999,15 +1105,16 @@ "remove_assets_shared_link_confirmation": "Ви впевнені, що хочете видалити {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}} з цього спільного посилання?", "remove_assets_title": "Видалити об'єкти?", "remove_custom_date_range": "Видалити користувацький діапазон дат", + "remove_deleted_assets": "Видалення автономних файлів", "remove_from_album": "Видалити з альбому", "remove_from_favorites": "Видалити з обраного", "remove_from_shared_link": "Видалити зі спільного посилання", - "remove_offline_files": "Видалення автономних файлів", "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": "Невідстежувані та відсутні файли будуть відображені тут", @@ -1020,6 +1127,7 @@ "reset_people_visibility": "Відновити видимість людей", "reset_settings_to_default": "", "reset_to_default": "Скидання до налаштувань за замовчуванням", + "resolve_duplicates": "Усунути дублікати", "resolved_all_duplicates": "Усі дублікати усунуто", "restore": "Відновити", "restore_all": "Відновити все", @@ -1038,6 +1146,7 @@ "say_something": "Скажіть що-небудь", "scan_all_libraries": "Сканувати всі бібліотеки", "scan_all_library_files": "Повторне сканування всіх файлів бібліотеки", + "scan_library": "Сканувати", "scan_new_library_files": "Сканування нових файлів бібліотеки", "scan_settings": "Налаштування сканування", "scanning_for_album": "Сканування альбому...", @@ -1053,9 +1162,12 @@ "search_for_existing_person": "Пошук існуючої особи", "search_no_people": "Немає людей", "search_no_people_named": "Немає осіб з іменем \"{name}\"", + "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", + "search_settings": "Налаштування пошуку", "search_state": "Пошук регіону...", + "search_tags": "Пошук тегів...", "search_timezone": "Пошук часового поясу...", "search_type": "Тип пошуку", "search_your_photos": "Шукати ваші знімки", @@ -1064,6 +1176,7 @@ "see_all_people": "Переглянути всіх людей", "select_album_cover": "Обрати обкладинку альбому", "select_all": "Вибрати все", + "select_all_duplicates": "Вибрати всі дублікати", "select_avatar_color": "Вибрати колір аватара", "select_face": "Виберіть обличчя", "select_featured_photo": "Обрати обране фото", @@ -1078,8 +1191,8 @@ "send_message": "Надіслати повідомлення", "send_welcome_email": "Надішліть вітальний лист", "server": "Сервер", - "server_offline": "Сервер відключено", - "server_online": "Сервер підключено", + "server_offline": "Сервер офлайн", + "server_online": "Сервер онлайн", "server_stats": "Статистика сервера", "server_version": "Версія сервера", "set": "Встановіть", @@ -1096,6 +1209,7 @@ "shared_by_user": "Спільний доступ з {user}", "shared_by_you": "Ви поділились", "shared_from_partner": "Фото від {partner}", + "shared_link_options": "Опції спільних посилань", "shared_links": "Спільні посилання", "shared_photos_and_videos_count": "{assetCount, plural, other {# спільні фотографії та відео.}}", "shared_with_partner": "Спільно з {partner}", @@ -1104,6 +1218,7 @@ "sharing_sidebar_description": "Відображати посилання на загальний доступ у бічній панелі", "shift_to_permanent_delete": "натисніть ⇧ щоб видалити об'єкт назавжди", "show_album_options": "Показати параметри альбому", + "show_albums": "Показувати альбоми", "show_all_people": "Показати всіх людей", "show_and_hide_people": "Показати та приховати людей", "show_file_location": "Показати розташування файлу", @@ -1118,11 +1233,18 @@ "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": "Сортувати альбоми за...", @@ -1130,10 +1252,12 @@ "sort_items": "Кількість елементів", "sort_modified": "Дата зміни", "sort_oldest": "Старі фото", - "sort_recent": "Нещодавні фото", + "sort_recent": "Нещодавні", "sort_title": "Заголовок", "source": "Джерело", - "stack": "Стек", + "stack": "У стопку", + "stack_duplicates": "Групувати дублікати", + "stack_select_one_photo": "Вибрати одне основне фото для групи", "stack_selected_photos": "Сгрупувати обрані фотографії", "stacked_assets_count": "Згруповано {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "stacktrace": "Стек викликів", @@ -1145,25 +1269,39 @@ "stop_photo_sharing": "Припинити надання ваших знімків?", "stop_photo_sharing_description": "{partner} більше не матиме доступу до ваших фотографій.", "stop_sharing_photos_with_user": "Припинити ділитися своїми фотографіями з цим користувачем", - "storage": "Місце для зберігання", + "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": "Синхронізувати", + "tag": "Тег", + "tag_assets": "Додати теги", + "tag_created": "Створено тег: {tag}", + "tag_feature_description": "Перегляд фотографій та відео, згрупованих за логічними темами тегів", + "tag_not_found_question": "Не вдається знайти тег? Створити новий тег.", + "tag_updated": "Оновлено тег: {tag}", + "tagged_assets": "Позначено тегом {count, plural, one {# актив} other {# активи}}", + "tags": "Теги", "template": "Шаблон", "theme": "Тема", "theme_selection": "Вибір теми", "theme_selection_description": "Автоматично встановлювати тему на світлу або темну залежно від системних налаштувань вашого браузера", "they_will_be_merged_together": "Вони будуть об'єднані разом", + "third_party_resources": "Ресурси третіх сторін", "time_based_memories": "Спогади, що базуються на часі", "timezone": "Часовий пояс", "to_archive": "Архів", "to_change_password": "Змінити пароль", "to_favorite": "Обране", "to_login": "Вхід", + "to_parent": "Повернутись назад", + "to_root": "На початок", "to_trash": "Смітник", "toggle_settings": "Перемикання налаштувань", "toggle_theme": "Перемикання теми", @@ -1171,7 +1309,7 @@ "total_usage": "Загальне використання", "trash": "Кошик", "trash_all": "Видалити все", - "trash_count": "Сміття {count}", + "trash_count": "Видалити {count, number}", "trash_delete_asset": "Смітник/Видалити ресурс", "trash_no_results_message": "Тут з'являтимуться видалені фото та відео.", "trashed_items_will_be_permanently_deleted_after": "Видалені елементи будуть остаточно видалені через {days, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", @@ -1185,12 +1323,15 @@ "unknown_album": "", "unknown_year": "Невідомий рік", "unlimited": "Без обмежень", + "unlink_motion_video": "Від'єднати рухоме відео", "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", "unnamed_album": "Альбом без назви", + "unnamed_album_delete_confirmation": "Ви впевнені, що бажаєте видалити цей альбом?", "unnamed_share": "Спільний доступ без назви", "unsaved_change": "Незбережена зміна", "unselect_all": "Зняти все", + "unselect_all_duplicates": "Скасувати вибір усіх дублікатів", "unstack": "Розібрати стек", "unstacked_assets_count": "Розгорнути {count, plural, one {# ресурс} few {# ресурси} many {# ресурсів} other {# ресурсів}}", "untracked_files": "Файли, що не відстежуються", @@ -1200,7 +1341,7 @@ "upload": "Завантажити", "upload_concurrency": "Паралельність завантаження", "upload_errors": "Завантаження завершено з {count, plural, one {# помилкою} few {# помилками} many {# помилками} other {# помилками}}, оновіть сторінку, щоб побачити нові завантажені ресурси.", - "upload_progress": "Залишилося {remaining} - Оброблено {processed}/{total}", + "upload_progress": "Залишилось {remaining, number} - Опрацьовано {processed, number}/{total, number}", "upload_skipped_duplicates": "Пропущено {count, plural, one {# дубльований ресурс} few {# дубльовані ресурси} many {# дубльованих ресурсів} other {# дубльованих ресурсів}}", "upload_status_duplicates": "Дублікати", "upload_status_errors": "Помилки", @@ -1214,6 +1355,8 @@ "user_license_settings": "Ліцензія", "user_license_settings_description": "Керування ліцензією", "user_liked": "{user} вподобав {type, select, photo {це фото} video {це відео} asset {цей ресурс} other {це}}", + "user_purchase_settings": "Придбати", + "user_purchase_settings_description": "Керувати вашою покупкою", "user_role_set": "Призначити {user} на роль {role}", "user_usage_detail": "Деталі використання користувача", "username": "Ім'я користувача", @@ -1224,6 +1367,8 @@ "version": "Версія", "version_announcement_closing": "Твій друг, Алекс", "version_announcement_message": "Привіт, друг! В нас є нова версія додатку. Будь ласка, відвідайте релізні нотатки і переконайтеся, що ваші файли docker-compose.yml та .env актуальні, щоб уникнути будь-яких помилок конфігурації, особливо якщо ви використовуєте WatchTower або інші механізми автоматичного оновлення додатку.", + "version_history": "Історія версій", + "version_history_item": "Встановлено {version} {date}", "video": "Відео", "video_hover_setting": "Відтворення мініатюри відео під час наведення курсору миші", "video_hover_setting_description": "Відтворювати зображення відео при наведенні курсора на елемент. Навіть якщо вимкнено, відтворення може бути запущено, навівши курсор на піктограму відтворення.", @@ -1233,6 +1378,7 @@ "view_album": "Переглянути альбом", "view_all": "Переглянути усі", "view_all_users": "Переглянути всіх користувачів", + "view_in_timeline": "Переглянути в хронології", "view_links": "Переглянути посилання", "view_next_asset": "Переглянути наступний ресурс", "view_previous_asset": "Переглянути попередній ресурс", @@ -1243,7 +1389,7 @@ "warning": "Попередження", "week": "Тиждень", "welcome": "Ласкаво просимо", - "welcome_to_immich": "Ласкаво просимо до immich", + "welcome_to_immich": "Ласкаво просимо до Immich", "year": "Рік", "years_ago": "{years, plural, one {# рік} few {# роки} many {# років} other {# років}} тому", "yes": "Так", diff --git a/web/src/lib/i18n/vi.json b/i18n/vi.json similarity index 55% rename from web/src/lib/i18n/vi.json rename to i18n/vi.json index 8b3719c796..2152814fb9 100644 --- a/web/src/lib/i18n/vi.json +++ b/i18n/vi.json @@ -7,7 +7,7 @@ "actions": "Các hành động", "active": "Đang hoạt động", "activity": "Hoạt động", - "activity_changed": "Hoạt động đang được {enabled, select, true {bật} other {tắt}}", + "activity_changed": "Hoạt động đã được {enabled, select, true {bật} other {tắt}}", "add": "Thêm", "add_a_description": "Thêm mô tả", "add_a_location": "Thêm vị trí", @@ -23,144 +23,161 @@ "add_to": "Thêm vào...", "add_to_album": "Thêm vào album", "add_to_shared_album": "Thêm vào album chia sẻ", - "added_to_archive": "Thêm vào kho lưu trữ", - "added_to_favorites": "Đã thêm vào mục yêu thích", - "added_to_favorites_count": "Đã thêm {count, number} vào mục yêu thích", + "added_to_archive": "Đã thêm vào Kho lưu trữ", + "added_to_favorites": "Đã thêm vào Mục yêu thích", + "added_to_favorites_count": "Đã thêm {count, number} vào Mục yêu thích", "admin": { - "add_exclusion_pattern_description": "Thêm quy tắc loại trừ. Hỗ trợ sử dụng ký tự *, **, và ?. Để bỏ qua tất cả các tệp bất kỳ trong thư mục tên \"Raw\", hãy dùng \"**/Raw/**\". Để bỏ qua các tệp có đuôi \".tif\", hãy dùng \"**/*.tif\". Để bỏ qua một đường dẫn đầy đủ, hãy dùng \"/path/to/ignore/**\".", - "authentication_settings": "Cài đặt đăng nhập", - "authentication_settings_description": "Quản lý mật khẩu, OAuth và các cài đặt đăng nhập khác", + "add_exclusion_pattern_description": "Thêm quy tắc loại trừ. Hỗ trợ sử dụng ký tự *, **, và ?. Để bỏ qua tất cả các tập tin bất kỳ trong thư mục tên \"Raw\", hãy dùng \"**/Raw/**\". Để bỏ qua các tập tin có đuôi \".tif\", hãy dùng \"**/*.tif\". Để bỏ qua một đường dẫn cố định, hãy dùng \"/path/to/ignore/**\".", + "asset_offline_description": "Ảnh thư viện ngoài này không còn trên ổ đĩa và đã bị chuyển vào thùng rác. Nếu ảnh đã bị di chuyển trong thư viện, kiểm tra dòng thời gian của bạn để tìm ảnh mới tương ứng. Để khôi phục, hãy đảm bảo Immich có thể truy cập đường dẫn ảnh bên dưới và quét lại thư viện.", + "authentication_settings": "Đăng nhập", + "authentication_settings_description": "Quản lý mật khẩu, OAuth và các cài đặt xác thực khác", "authentication_settings_disable_all": "Bạn có chắc chắn muốn vô hiệu hoá tất cả các phương thức đăng nhập? Đăng nhập sẽ bị vô hiệu hóa hoàn toàn.", - "authentication_settings_reenable": "Để bật lại, dùng Lệnh máy chủ.", + "authentication_settings_reenable": "Để bật lại, dùng Lệnh Máy chủ.", "background_task_job": "Các tác vụ nền", "check_all": "Chọn tất cả", - "cleared_jobs": "Đã xoá tác vụ cho: {job}", - "config_set_by_file": "Cấu hình hiện tại đang được đặt bởi tệp cấu hình", + "cleared_jobs": "Đã xoá các tác vụ: {job}", + "config_set_by_file": "Cấu hình hiện tại đang được đặt bởi một tập tin cấu hình", "confirm_delete_library": "Bạn có chắc chắn muốn xóa thư viện {library} không?", - "confirm_delete_library_assets": "Bạn có chắc chắn muốn xóa thư viện này không? Thao tác này sẽ xóa {count, plural, one {# ảnh được chứa} other {tất cả # ảnh được chứa}} khỏi Immich và không thể hoàn tác. Các tệp sẽ vẫn còn trên đĩa.", - "confirm_email_below": "Để xác nhận, nhập \"{email}\" ở dưới", + "confirm_delete_library_assets": "Bạn có chắc chắn muốn xóa thư viện này không? Thao tác này sẽ xóa {count, plural, one {# ảnh} other {tất cả # ảnh}} có trong Immich và không thể hoàn tác. Các tập tin sẽ vẫn còn trên ổ đĩa.", + "confirm_email_below": "Để xác nhận, nhập \"{email}\" bên dưới", "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", + "create_job": "Tạo tác vụ", "crontab_guru": "Crontab Guru", "disable_login": "Vô hiệu hoá đăng nhập", "disabled": "", - "duplicate_detection_job_description": "Sử dụng machine learning để phát hiện các hình ảnh giống nhau. Dựa vào Tìm kiếm Thông Minh", - "exclusion_pattern_description": "Quy tắc loại trừ cho bạn bỏ qua các tệp và thư mục khi quét thư viện của bạn. Điều này hữu ích nếu bạn có các thư mục chứa tệp bạn không muốn nhập, chẳng hạn như tệp RAW.", + "duplicate_detection_job_description": "Sử dụng Học máy để phát hiện các hình ảnh giống nhau. Dựa vào Tìm kiếm Thông Minh", + "exclusion_pattern_description": "Quy tắc loại trừ cho bạn bỏ qua các tập tin và thư mục khi quét thư viện của bạn. Điều này hữu ích nếu bạn có các thư mục chứa tập tin bạn không muốn nhập, chẳng hạn như các tập tin RAW.", "external_library_created_at": "Thư viện bên ngoài (được tạo vào {date})", "external_library_management": "Quản lý thư viện bên ngoài", - "face_detection": "Nhận diện khuôn mặt", - "face_detection_description": "Sử dụng machine learning để phát hiện các khuôn mặt trong ảnh. Với video, chỉ thực hiện trên ảnh thu nhỏ. Xử lý lại tất cả các hình ảnh. Các hỉnh ảnh trong hàng đợi bị bỏ lỡ chưa được xử lý. Các khuôn mặt được phát hiện sẽ được xếp vào hàng đợi cho quá trình Nhận dạng khuôn mặt sau khi quá trình Phát hiện khuôn mặt hoàn tất, nhóm chúng vào người hiện có hoặc tạo người mới.", - "facial_recognition_job_description": "Nhóm các khuôn mặt đã phát hiện thành người. Bước này được thực hiện sau khi Phát hiện khuôn mặt hoàn tất. Xử lý lại việc nhóm cho toàn bộ khuôn mặt. Các khuôn mặt trong hàng đợi bị bỏ lỡ chưa được gán cho người nào.", + "face_detection": "Phát hiện khuôn mặt", + "face_detection_description": "Sử dụng Học máy để nhận dạng khuôn mặt trong ảnh. Đối với video, sẽ sử dụng hình ảnh thu nhỏ. \"Làm Mới\" sẽ xử lý lại tất cả các hình. \"Xử Lý Lại\" sẽ xoá hết tất cả dữ liệu khuôn mặt và nhận dạng lại. \"Còn Thiếu\" sẽ xử lý các ảnh còn thiếu. Các khuôn mặt được phát hiện sẽ được xử lý bởi tác vụ Nhận Dạng Khuôn Mặt để nhóm chúng vào những người đã có hoặc người mới.", + "facial_recognition_job_description": "Nhóm các khuôn mặt đã phát hiện thành người. Bước này được thực hiện sau khi tác vụ Phát hiện Khuôn mặt hoàn tất. \"Xử Lý Lại\" sẽ nhóm lại tất cả các khuôn mặt. \"Còn Thiếu\" sẽ xử lý các khuôn mặt chưa có gán với người nào.", "failed_job_command": "Lệnh {command} không thực hiện được tác vụ: {job}", - "force_delete_user_warning": "CẢNH BÁO: Thao tác này sẽ ngay lập tức xoá người dùng và tất cả ảnh. Hành động này không thể hoàn tác và các tệp không thể khôi phục.", + "force_delete_user_warning": "CẢNH BÁO: Thao tác này sẽ ngay lập tức xoá người dùng và tất cả ảnh. Hành động này không thể hoàn tác và các tập tin không thể khôi phục.", "forcing_refresh_library_files": "Làm mới toàn bộ thư viện ảnh", - "image_format_description": "Tệp WebP dung lượng nhỏ hơn JPEG, nhưng mã hóa chậm hơn.", - "image_prefer_embedded_preview": "Ưu tiên ảnh xem trước đã nhúng", + "image_format": "Định dạng", + "image_format_description": "Định dạng WebP dung lượng nhỏ hơn JPEG, nhưng mã hóa chậm hơn.", + "image_prefer_embedded_preview": "Ưu tiên ảnh xem trước đi kèm", "image_prefer_embedded_preview_setting_description": "Ứng dụng sẽ sử dụng ảnh xem trước trong ảnh RAW khi có sẵn để xử lý hình ảnh. Điều này có thể giúp tái tạo màu sắc chính xác hơn cho một số hình ảnh, nhưng chất lượng của ảnh xem trước phụ thuộc vào máy ảnh và có thể bị nén.", "image_prefer_wide_gamut": "Ưu tiên gam màu mở rộng", - "image_prefer_wide_gamut_setting_description": "Hiển thị ảnh thu nhỏ ở gam màu Display P3. Điều này giúp giữ màu sắc rực rỡ của những hình ảnh có gam màu rộng, nhưng hình ảnh có thể trông khác trên các thiết bị cũ và trình duyệt cũ. Hình ảnh sRGB được giữ nguyên để tránh thay đổi màu sắc.", + "image_prefer_wide_gamut_setting_description": "Hiển thị hình thu nhỏ ở gam màu Display P3. Điều này giúp giữ màu sắc rực rỡ của những hình ảnh có gam màu rộng, nhưng hình ảnh có thể trông khác trên các thiết bị cũ và trình duyệt cũ. Hình ảnh sRGB được giữ nguyên để tránh thay đổi màu sắc.", + "image_preview_description": "Hình ảnh kích thước trung bình đã loại bỏ metadata, được sử dụng khi xem một hình duy nhất và cho Học máy", "image_preview_format": "Định dạng xem trước", + "image_preview_quality_description": "Chất lượng xem trước từ 1-100. Càng cao càng tốt, nhưng sẽ tạo ra các tập tin lớn hơn có thể làm giảm khả năng phản hồi của ứng dụng. Sử dụng giá trị thấp có thể ảnh hưởng đến chất lượng tác vụ Học máy.", "image_preview_resolution": "Độ phân giải xem trước", "image_preview_resolution_description": "Được sử dụng khi xem một bức ảnh và cho machine learning. Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", + "image_preview_title": "Cài đặt Xem trước", "image_quality": "Chất lượng", - "image_quality_description": "Chất lượng hình ảnh từ 1 - 100. Giá trị càng cao hình ảnh đẹp hơn nhưng kích thước tệp sẽ lớn, lựa chọn này ảnh hưởng tới ảnh xem trước và ảnh thu nhỏ.", - "image_settings": "Cài đặt hình ảnh", + "image_quality_description": "Chất lượng hình ảnh từ 1 - 100. Giá trị càng cao hình ảnh đẹp hơn nhưng kích thước tập tin sẽ lớn, lựa chọn này ảnh hưởng tới ảnh xem trước và ảnh thu nhỏ.", + "image_resolution": "Độ phân giải", + "image_resolution_description": "Độ phân giải cao hơn sẽ rõ nét hơn nhưng tốn nhiều thời gian hơn để mã hóa, kích thước tập tin lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", + "image_settings": "Hình ảnh", "image_settings_description": "Quản lý chất lượng và độ phân giải của hình ảnh được tạo", + "image_thumbnail_description": "Hình thu nhỏ kích thước nhỏ đã loại bỏ metadata, dùng khi xem nhiều ảnh cùng lúc, ví dụ như xem Dòng Thời gian chính", "image_thumbnail_format": "Định dạng ảnh thu nhỏ", + "image_thumbnail_quality_description": "Chất lượng hình thu nhỏ từ 1-100. Càng cao càng tốt, nhưng sẽ tạo ra các tập tin lớn hơn có thể làm giảm khả năng phản hồi của ứng dụng.", "image_thumbnail_resolution": "Độ phân giải ảnh thu nhỏ", "image_thumbnail_resolution_description": "Dùng khi xem một nhóm các ảnh (dòng thời gian chính, xem album, v.v.). Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", + "image_thumbnail_title": "Cài đặt hình thu nhỏ", "job_concurrency": "{job} thực hiện đồng thời", + "job_created": "Tác vụ đã được tạo", "job_not_concurrency_safe": "Tác vụ này không an toàn để thực hiện đồng thời.", - "job_settings": "Cài đặt tác vụ công việc", - "job_settings_description": "Quản lý tác vụ thực hiện đồng thời", + "job_settings": "Tác vụ", + "job_settings_description": "Quản lý mức độ thực hiện đồng thời của tác vụ", "job_status": "Trạng thái tác vụ", - "jobs_delayed": "{jobCount, plural, other {# bị hoãn lại}}", - "jobs_failed": "{jobCount, plural, other {# bị thất bại}}", - "library_created": "Thư viện được tạo: {library}", + "jobs_delayed": "{jobCount, plural, other {# tác vụ bị hoãn lại}}", + "jobs_failed": "{jobCount, plural, other {# tác vụ bị thất bại}}", + "library_created": "Đã tạo thư viện: {library}", "library_cron_expression": "Cú pháp Cron", - "library_cron_expression_description": "Đặt lịch quét bằng định dạng cron. Để biết thêm thông tin, vui lòng tham khảo ví dụ. Crontab Guru", - "library_cron_expression_presets": "Thiết lập lịch quét", + "library_cron_expression_description": "Đặt lịch quét bằng định dạng Cron. Để biết thêm về định dạng hãy tham khảo Crontab Guru", + "library_cron_expression_presets": "Các mẫu biểu thức Cron", "library_deleted": "Thư viện đã bị xoá", - "library_import_path_description": "Chọn thư mục để nhập. Ứng dụng sẽ quét tất cả hình ảnh và video trong thư mục này và các thư mục con.", + "library_import_path_description": "Chọn thư mục để nhập. Ứng dụng sẽ quét tất cả hình ảnh và video trong thư mục này bao gồm các thư mục con.", "library_scanning": "Quét định kỳ", "library_scanning_description": "Cấu hình quét thư viện định kỳ", "library_scanning_enable_description": "Bật quét thư viện định kỳ", "library_settings": "Thư viện bên ngoài", "library_settings_description": "Quản lý cài đặt thư viện bên ngoài", - "library_tasks_description": "Xử lý các tác vụ thư viện", - "library_watching_enable_description": "Tự động cập nhật các tệp bị thay đổi trong thư viện bên ngoài", + "library_tasks_description": "Thực hiện các tác vụ thư viện", + "library_watching_enable_description": "Tự động cập nhật các tập tin bị thay đổi trong thư viện bên ngoài", "library_watching_settings": "Theo dõi thư viện (THỬ NGHIỆM)", - "library_watching_settings_description": "Tự động cập nhật khi các tệp bị thay đổi", + "library_watching_settings_description": "Tự động cập nhật khi các tập tin bị thay đổi", "logging_enable_description": "Bật ghi nhật ký", "logging_level_description": "Khi được bật, thiết lập mức ghi nhật ký.", "logging_settings": "Ghi nhật ký", "machine_learning_clip_model": "Mô hình CLIP", "machine_learning_clip_model_description": "Tên của mô hình CLIP được liệt kê tại đây. Bạn cần chạy lại tác vụ \"Tìm kiếm thông minh\" cho tất cả hình ảnh sau khi thay đổi mô hình.", - "machine_learning_duplicate_detection": "Phát hiện ảnh trùng lặp", + "machine_learning_duplicate_detection": "Phát hiện Trùng lặp", "machine_learning_duplicate_detection_enabled": "Bật phát hiện ảnh trùng lặp", - "machine_learning_duplicate_detection_enabled_description": "Nếu bị vô hiệu hoá, các ảnh trùng lặp giống hệt nhau vẫn sẽ bị loại bỏ.", + "machine_learning_duplicate_detection_enabled_description": "Nếu bị tắt, các ảnh trùng lặp giống hệt nhau vẫn sẽ bị loại bỏ.", "machine_learning_duplicate_detection_setting_description": "Sử dụng vector nhúng CLIP để tìm kiếm ảnh trùng lặp", - "machine_learning_enabled": "Bật machine learning", - "machine_learning_enabled_description": "Nếu bị vô hiệu hoá, tất cả các tính năng ML sẽ bị vô hiệu hoá kể các cài đặt bên dưới.", - "machine_learning_facial_recognition": "Nhận dạng khuôn mặt", + "machine_learning_enabled": "Bật Học máy", + "machine_learning_enabled_description": "Nếu bị tắt, tất cả các tính năng và cài đặt Học máy sẽ bị loại bỏ.", + "machine_learning_facial_recognition": "Nhận dạng Khuôn mặt", "machine_learning_facial_recognition_description": "Phát hiện, nhận dạng và nhóm các khuôn mặt trong ảnh", "machine_learning_facial_recognition_model": "Mô hình nhận dạng khuôn mặt", - "machine_learning_facial_recognition_model_description": "Các mô hình được liệt kê theo thứ tự kích thước giảm dần. Mô hình càng lớn, kết quả càng chính xác nhưng sẽ chạy chậm và tốn nhiều bộ nhớ hơn. Lưu ý rằng sau khi thay đổi mô hình, bạn cần chạy lại tính năng \"Phát hiện Khuôn mặt\" cho tất cả hình ảnh.", + "machine_learning_facial_recognition_model_description": "Các mô hình được liệt kê theo thứ tự kích thước giảm dần. Mô hình càng lớn, kết quả càng chính xác nhưng sẽ chạy chậm và tốn nhiều bộ nhớ hơn. Lưu ý rằng sau khi thay đổi mô hình, bạn cần chạy lại tác vụ \"Phát hiện Khuôn mặt\" cho tất cả hình ảnh.", "machine_learning_facial_recognition_setting": "Bật nhận dạng khuôn mặt", - "machine_learning_facial_recognition_setting_description": "Nếu tính năng này bị vô hiệu hóa, hình ảnh sẽ không được mã hóa để nhận diện khuôn mặt và sẽ không xuất hiện trong phần Mọi người trong trang Khám phá.", + "machine_learning_facial_recognition_setting_description": "Nếu tính năng này bị tắt, hình ảnh sẽ không được mã hóa để nhận dạng khuôn mặt và sẽ không xuất hiện trong mục Mọi người trên trang Khám phá.", "machine_learning_max_detection_distance": "Khoảng cách phát hiện tối đa", "machine_learning_max_detection_distance_description": "Khoảng cách tối đa để hai ảnh được coi là trùng lặp, dao động từ 0,001 đến 0,1. Giá trị càng cao sẽ phát hiện được nhiều ảnh trùng lặp hơn, nhưng có thể bao gồm cả ảnh không thực sự giống nhau.", "machine_learning_max_recognition_distance": "Khoảng cách nhận dạng tối đa", - "machine_learning_max_recognition_distance_description": "Khoảng cách tối đa để hai khuôn mặt được coi là cùng một người, dao động từ 0-2. Giảm giá trị này có thể ngăn chặn việc gán nhãn hai người cùng một người, trong khi tăng giá trị này có thể ngăn chặn việc gán nhãn cùng một người là hai người khác nhau. Lưu ý rằng việc gộp hai người lại với nhau dễ dàng hơn là tách một người thành hai, vì vậy hãy ưu tiên giá trị thấp khi có thể.", - "machine_learning_min_detection_score": "Hệ số nhận dạng tối thiểu", - "machine_learning_min_detection_score_description": "Hệ số tự tin tối thiểu để khuôn mặt được phát hiện, từ 0 - 1. Hệ số càng thấp, nhiều khuôn mặt sẽ được nhận diện hơn nhưng có thể xảy ra sai sót.", - "machine_learning_min_recognized_faces": "Số khuôn mặt nhận được tối thiểu", - "machine_learning_min_recognized_faces_description": "Tối thiểu bao nhiêu khuôn mặt được nhận diện để tạo một người. Tăng giá trị này sẽ khiến cho Nhận diện Khuôn mặt chính xác hơn, nhưng sẽ tăng khả năng một khuôn mặt sẽ không được gán với 1 người.", - "machine_learning_settings": "Cài đặt Machine Learning", - "machine_learning_settings_description": "Quản lý các tính năng và cài đặt của machine learning", - "machine_learning_smart_search": "Tìm kiếm thông minh", - "machine_learning_smart_search_description": "Tìm kiếm hình ảnh theo ngữ nghĩa với CLIP", - "machine_learning_smart_search_enabled": "Bật tìm kiếm thông minh", - "machine_learning_smart_search_enabled_description": "Nếu vô hiệu hoá, hình ảnh sẽ không được mã hoá để tìm kiếm thông minh.", - "machine_learning_url_description": "Địa chỉ máy chủ machine learning", - "manage_concurrency": "Quản lý tác vụ", + "machine_learning_max_recognition_distance_description": "Khoảng cách tối đa để hai khuôn mặt được coi là cùng một người, dao động từ 0-2. Giảm giá trị này có thể ngăn chặn việc gán nhãn hai người cùng một người, trong khi tăng giá trị này có thể ngăn chặn việc gán nhãn cùng một người là hai người khác nhau. Lưu ý rằng việc hợp nhất hai người lại với nhau dễ dàng hơn là tách một người thành hai, vì vậy hãy ưu tiên giá trị thấp khi có thể.", + "machine_learning_min_detection_score": "Mức phát hiện tối thiểu", + "machine_learning_min_detection_score_description": "Mức điểm tin cậy tối thiểu để phát hiện khuôn mặt, từ 0 đến 1. Giá trị càng thấp, nhiều khuôn mặt sẽ được phát hiện nhưng có thể tăng khả năng phát hiện sai.", + "machine_learning_min_recognized_faces": "Số khuôn mặt tối thiểu để nhận dạng", + "machine_learning_min_recognized_faces_description": "Số khuôn mặt tối thiểu cần nhận dạng để tạo thành một người. Tăng số lượng này sẽ làm cho Nhận dạng khuôn mặt chính xác hơn, nhưng sẽ tăng khả năng một khuôn mặt không được gán cho người phù hợp.", + "machine_learning_settings": "Cài đặt Học máy", + "machine_learning_settings_description": "Quản lý các tính năng và cài đặt Học máy", + "machine_learning_smart_search": "Tìm kiếm Thông minh", + "machine_learning_smart_search_description": "Tìm kiếm hình ảnh theo ngữ cảnh với CLIP", + "machine_learning_smart_search_enabled": "Bật Tìm kiếm Thông minh", + "machine_learning_smart_search_enabled_description": "Nếu tắt, hình ảnh sẽ không được mã hoá để tìm kiếm thông minh.", + "machine_learning_url_description": "Địa chỉ máy chủ Học máy", + "manage_concurrency": "Quản lý Tác vụ", "manage_log_settings": "Quản lý cài đặt nhật ký", "map_dark_style": "Giao diện tối", "map_enable_description": "Bật tính năng bản đồ", - "map_gps_settings": "Cài đặt bản đồ & GPS", - "map_gps_settings_description": "Quản lý cài đặt bản đồ & GPS (Mã hóa địa lý đảo ngược)", + "map_gps_settings": "Bản đồ & GPS", + "map_gps_settings_description": "Quản lý cài đặt Bản đồ & GPS (Mã hóa địa lý ngược)", + "map_implications": "Tính năng bản đồ phụ thuộc vào dịch vụ thẻ bản đồ bên ngoài (tiles.immich.cloud)", "map_light_style": "Giao diện sáng", - "map_manage_reverse_geocoding_settings": "Quản lý cài đặtMã hóa Địa lý Đảo ngược (Reverse Geocoding)", - "map_reverse_geocoding": "Mã hoá Địa lý Đảo ngược", - "map_reverse_geocoding_enable_description": "Bật mã hoá địa lý đảo ngược", - "map_reverse_geocoding_settings": "Cài đặt Mã hoá Địa lý Đảo ngược", - "map_settings": "Cài đặt Bản đồ", - "map_settings_description": "Quản lý các cài đặt bản đồ", - "map_style_description": "Đường dẫn URL đến file tuỳ biến bản đồ style.json", - "metadata_extraction_job": "Trích xuất metadata", - "metadata_extraction_job_description": "Trích xuất metadata từ mỗi ảnh, ví dụ như GPS và kích thước", + "map_manage_reverse_geocoding_settings": "Quản lý cài đặt Mã hóa địa lý ngược", + "map_reverse_geocoding": "Mã hoá Địa lý Ngược (Reverse Geocoding)", + "map_reverse_geocoding_enable_description": "Bật mã hoá địa lý ngược", + "map_reverse_geocoding_settings": "Cài đặt Mã hoá Địa lý Ngược (Reverse Geocoding)", + "map_settings": "Bản đồ", + "map_settings_description": "Quản lý cài đặt bản đồ", + "map_style_description": "Đường dẫn URL đến tập tin tuỳ biến bản đồ style.json", + "metadata_extraction_job": "Trích xuất Metadata", + "metadata_extraction_job_description": "Trích xuất Metadata từ mỗi ảnh, chẳng hạn như GPS, khuôn mặt và độ phân giải", + "metadata_faces_import_setting": "Bật tính năng nhập khuôn mặt", + "metadata_faces_import_setting_description": "Nhập khuôn mặt từ dữ liệu EXIF hình ảnh và tập tin đi kèm", + "metadata_settings": "Cài đặt Metadata", + "metadata_settings_description": "Quản lý cài đặt Metadata", "migration_job": "Di chuyển dữ liệu", - "migration_job_description": "Di chuyển hình thu nhỏ của nội dung và khuôn mặt sang cấu trúc thư mục mới nhất", + "migration_job_description": "Di chuyển hình thu nhỏ của các ảnh và khuôn mặt sang cấu trúc thư mục mới", "no_paths_added": "Không có đường dẫn nào được thêm vào", - "no_pattern_added": "Không có mẫu nào được thêm vào", - "note_apply_storage_label_previous_assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho nội dung đã tải lên trước đó, hãy chạy lệnh", + "no_pattern_added": "Không có quy tắc nào được thêm vào", + "note_apply_storage_label_previous_assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho nội dung đã tải lên trước đó, hãy chạy", "note_cannot_be_changed_later": "LƯU Ý: Cài đặt này không thể thay đổi được sau khi lưu!", - "note_unlimited_quota": "Lưu ý: Nhập 0 để không giới hạn", + "note_unlimited_quota": "Lưu ý: Nhập 0 để hạn mức không giới hạn", "notification_email_from_address": "Địa chỉ email người gửi", - "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", + "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", "notification_email_host_description": "Địa chỉ máy chủ email (ví dụ: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Bỏ qua các lỗi chứng chỉ", "notification_email_ignore_certificate_errors_description": "Bỏ qua lỗi xác thực chứng chỉ TLS (không khuyến nghị)", "notification_email_password_description": "Mật khẩu dùng để xác thực với máy chủ email", "notification_email_port_description": "Cổng của máy chủ email (ví dụ 25, 465, hoặc 587)", - "notification_email_sent_test_email_button": "Gửi email thử nghiệm và lưu", + "notification_email_sent_test_email_button": "Gửi email kiểm tra và lưu", "notification_email_setting_description": "Cài đặt gửi thông báo qua email", - "notification_email_test_email": "Gửi email thử nghiệm", - "notification_email_test_email_failed": "Gửi email thử nghiệm thất bại, vui lòng kiểm tra các thông tin của bạn", + "notification_email_test_email": "Đã gửi email kiểm tra", + "notification_email_test_email_failed": "Gửi email thử nghiệm thất bại, vui lòng kiểm tra các giá trị của bạn", "notification_email_test_email_sent": "Một email thử nghiệm đã được gửi tới {email}. Vui lòng kiểm tra hộp thư của bạn.", "notification_email_username_description": "Tên đăng nhập email để xác thực với máy chủ email", "notification_enable_email_notifications": "Bật thông báo qua email", - "notification_settings": "Cài đặt thông báo", + "notification_settings": "Thông báo", "notification_settings_description": "Quản lý các cài đặt thông báo, bao gồm email", "oauth_auto_launch": "Tự động khởi chạy OAuth", "oauth_auto_launch_description": "Tự động đăng nhập bằng tài khoản OAuth khi bạn truy cập trang đăng nhập", @@ -173,171 +190,178 @@ "oauth_issuer_url": "Địa chỉ nhà cung cấp OAuth", "oauth_mobile_redirect_uri": "URI chuyển hướng trên thiết bị di động", "oauth_mobile_redirect_uri_override": "Ghi đè URI chuyển hướng cho thiết bị di động", - "oauth_mobile_redirect_uri_override_description": "Bật khi URI chuyển hướng 'app.immich:/' không hợp lệ.", - "oauth_profile_signing_algorithm": "Thuật toán ký hồ sơ người dùng", - "oauth_profile_signing_algorithm_description": "Thuật toán được sử dụng để ký hồ sơ người dùng.", + "oauth_mobile_redirect_uri_override_description": "Bật khi nhà cung cấp OAuth không cho phép URI di động, như '{callback}'", + "oauth_profile_signing_algorithm": "Thuật toán ký vào hồ sơ người dùng", + "oauth_profile_signing_algorithm_description": "Thuật toán được sử dụng để ký vào hồ sơ người dùng.", "oauth_scope": "Phạm vi", "oauth_settings": "OAuth", "oauth_settings_description": "Quản lý cài đặt đăng nhập OAuth", "oauth_settings_more_details": "Để biết thêm chi tiết về tính năng này, hãy tham khảo tài liệu.", "oauth_signing_algorithm": "Thuật toán ký", "oauth_storage_label_claim": "Claim cho nhãn lưu trữ", - "oauth_storage_label_claim_description": "Tự động gán nhãn cho nơi lưu trữ của người dùng theo giá trị của claim này.", - "oauth_storage_quota_claim": "Claim cho dung lượng lưu trữ", - "oauth_storage_quota_claim_description": "Tự động đặt dung lượng lưu trữ của người dùng theo giá trị của claim này.", + "oauth_storage_label_claim_description": "Tự động đặt nhãn lưu trữ của người dùng theo giá trị của claim này.", + "oauth_storage_quota_claim": "Claim cho hạn mức lưu trữ", + "oauth_storage_quota_claim_description": "Tự động đặt hạn mức lưu trữ của người dùng theo giá trị của claim này.", "oauth_storage_quota_default": "Hạn mức lưu trữ mặc định (GiB)", - "oauth_storage_quota_default_description": "Hạn mức (GiB) sẽ được sử dụng khi không có yêu cầu nào được cung cấp (Nhập 0 để không giới hạn).", + "oauth_storage_quota_default_description": "Hạn mức (GiB) sẽ được sử dụng khi không có yêu cầu nào được cung cấp (Nhập 0 để hạn mức không giới hạn).", "offline_paths": "Các đường dẫn ngoại tuyến", - "offline_paths_description": "Những đường dẫn này có thể do những file không nằm trong nơi lưu trữ ngoài bị xoá thủ công.", + "offline_paths_description": "Những đường dẫn này có thể là do việc xóa thủ công các tập tin không thuộc thư viện bên ngoài.", "password_enable_description": "Đăng nhập với email và mật khẩu", "password_settings": "Mật khẩu đăng nhập", "password_settings_description": "Quản lý cài đặt mật khẩu đăng nhập", "paths_validated_successfully": "Tất cả các đường dẫn được xác minh thành công", + "person_cleanup_job": "Dọn dẹp người", "quota_size_gib": "Hạn mức (GiB)", "refreshing_all_libraries": "Làm mới tất cả các thư viện", "registration": "Đăng ký Quản trị viên", "registration_description": "Vì bạn là người dùng đầu tiên, bạn sẽ trở thành Quản trị viên và chịu trách nhiệm cho việc quản lý hệ thống. Ngoài ra, bạn có thể thêm các người dùng khác.", - "removing_offline_files": "Đang xoá các tệp ngoại tuyến", + "removing_deleted_files": "Đang xoá các tập tin ngoại tuyến", "repair_all": "Sửa chữa tất cả", - "repair_matched_items": "Đã tìm thấy {count, plural, one {# một} other {# các}} file trùng khớp", - "repaired_items": "Đã khôi phục {count, plural, one{# một} other {# các}} file", + "repair_matched_items": "Đã tìm thấy {count, plural, one {# mục} other {# mục}} trùng khớp", + "repaired_items": "Đã sửa chữa {count, plural, one{# mục} other {# mục}}", "require_password_change_on_login": "Yêu cầu người dùng thay đổi mật khẩu trong lần đăng nhập đầu tiên", "reset_settings_to_default": "Đặt lại cài đặt về mặc định", "reset_settings_to_recent_saved": "Đặt lại cài đặt về cài đặt trước đó", - "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tệp đã thay đổi", - "scanning_library_for_new_files": "Đang quét thư viện để tìm các tệp mới", + "scanning_library": "Quét thư viện", + "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tập tin đã thay đổi", + "scanning_library_for_new_files": "Đang quét thư viện để tìm các tập tin mới", + "search_jobs": "Tìm kiếm tác vụ...", "send_welcome_email": "Gửi email chào mừng", "server_external_domain_settings": "Tên miền công khai", - "server_external_domain_settings_description": "Tên miền dành cho các liên kết được chia sẻ công khai, bao gồm http(s)://", - "server_settings": "Cài đặt máy chủ", + "server_external_domain_settings_description": "Tên miền dành cho các liên kết chia sẻ công khai, bao gồm http(s)://", + "server_settings": "Máy chủ", "server_settings_description": "Quản lý cài đặt máy chủ", - "server_welcome_message": "Tin nhắn chào mừng", - "server_welcome_message_description": "Thêm tin nhắn được hiển thị trên trang đăng nhập.", + "server_welcome_message": "Thông điệp chào mừng", + "server_welcome_message_description": "Thông điệp chào mừng được hiển thị trên trang đăng nhập.", "sidecar_job": "Siêu dữ liệu đi kèm", - "sidecar_job_description": "Tìm hoặc đồng bộ các file metadata sidecar từ hệ thống", - "slideshow_duration_description": "Số giây để hiển thị mỗi hình ảnh", + "sidecar_job_description": "Tìm hoặc đồng bộ các tập tin siêu dữ liệu đi kèm từ hệ thống", + "slideshow_duration_description": "Số giây để hiển thị cho từng ảnh", "smart_search_job_description": "Chạy machine learning trên toàn bộ ảnh để hỗ trợ tìm kiếm thông minh", - "storage_template_date_time_description": "Dấu thời gian tạo tệp tin được sử dụng cho thông tin ngày giờ", + "storage_template_date_time_description": "Dấu thời gian tạo ảnh được sử dụng cho thông tin ngày giờ", "storage_template_date_time_sample": "Thời gian mẫu {date}", "storage_template_enable_description": "Bật công cụ mẫu lưu trữ", - "storage_template_hash_verification_enabled": "Đã bật xác minh băm", + "storage_template_hash_verification_enabled": "Bật xác minh băm", "storage_template_hash_verification_enabled_description": "Bật xác minh băm, không tắt tính năng này trừ khi bạn chắc chắn về các rủi ro có thể xảy ra", - "storage_template_migration": "Dịch chuyển mẫu lưu trữ", - "storage_template_migration_description": "Áp dụng {template} hiện tại cho các tệp tin đã được tải lên trước đây", - "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các tệp tin mới. Để áp dụng mẫu một cách ngược lại cho các tệp tin đã được tải lên trước đây, hãy chạy {job}.", - "storage_template_migration_job": "Công việc dịch chuyển mẫu lưu trữ", - "storage_template_more_details": "Cần thêm thông tin chi tiết về tính năng này, vui lòng tham khảo Mẫu Lưu trữ và các hệ quả của nó", - "storage_template_onboarding_description": "Khi được bật, tính năng này sẽ tự động tổ chức các tệp tin dựa trên mẫu do người dùng định nghĩa. Do các vấn đề về tính ổn định, tính năng này đã bị tắt theo mặc định. Để biết thêm thông tin, vui lòng xem tài liệu.", + "storage_template_migration": "Di chuyển mẫu lưu trữ", + "storage_template_migration_description": "Áp dụng {template} hiện tại cho các ảnh đã được tải lên trước đây", + "storage_template_migration_info": "Các thay đổi mẫu chỉ áp dụng cho các ảnh mới. Để áp dụng lại mẫu cho các ảnh đã được tải lên trước đây, hãy chạy {job}.", + "storage_template_migration_job": "Tác vụ di chuyển mẫu lưu trữ", + "storage_template_more_details": "Cần thêm thông tin chi tiết về tính năng này, vui lòng tham khảo Mẫu lưu trữ và các hệ quả của nó", + "storage_template_onboarding_description": "Khi được bật, tính năng này sẽ tự động sắp xếp các tập tin dựa trên mẫu do người dùng định nghĩa. Do các vấn đề về độ ổn định nên tính năng này đã bị tắt theo mặc định. Để biết thêm thông tin, vui lòng xem tài liệu.", "storage_template_path_length": "Giới hạn độ dài đường dẫn xấp xỉ: {length, number}/{limit, number}", - "storage_template_settings": "Mẫu Lưu trữ", - "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tệp của nội dung tải lên", - "storage_template_user_label": "Cụm từ {label} là Nhãn Lưu trữ của người dùng", + "storage_template_settings": "Mẫu lưu trữ", + "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tập tin của ảnh tải lên", + "storage_template_user_label": "Cụm từ {label} là Nhãn lưu trữ của người dùng", "system_settings": "Cài đặt hệ thống", - "theme_custom_css_settings": "Tuỳ chỉnh CSS", + "tag_cleanup_job": "Dọn dẹp thẻ", + "theme_custom_css_settings": "CSS tùy chỉnh", "theme_custom_css_settings_description": "Cascading Style Sheets cho phép tùy chỉnh thiết kế của Immich.", - "theme_settings": "Cài đặt chủ đề", + "theme_settings": "Chủ đề", "theme_settings_description": "Quản lý tùy chỉnh giao diện web của Immich", - "these_files_matched_by_checksum": "Các tệp tin này khớp với các giá trị băm của chúng", - "thumbnail_generation_job": "Tạo Hình thu nhỏ", - "thumbnail_generation_job_description": "Tạo hình thu nhỏ lớn, nhỏ và mờ cho mỗi tệp tin, cũng như hình thu nhỏ cho mỗi người", + "these_files_matched_by_checksum": "Các tập tin này khớp với các giá trị băm của chúng", + "thumbnail_generation_job": "Tạo hình thu nhỏ", + "thumbnail_generation_job_description": "Tạo hình thu nhỏ lớn, nhỏ và mờ cho mỗi ảnh, cũng như hình thu nhỏ cho mỗi người", "transcode_policy_description": "", "transcoding_acceleration_api": "API Tăng tốc", - "transcoding_acceleration_api_description": "API này sẽ tương tác với thiết bị của bạn để tăng tốc quá trình chuyển mã. Cài đặt này là 'cố gắng tốt nhất': nó sẽ quay lại chuyển mã phần mềm nếu gặp lỗi. VP9 có thể hoạt động hoặc không tùy thuộc vào phần cứng của bạn.", + "transcoding_acceleration_api_description": "API này sẽ tương tác với thiết bị của bạn để tăng tốc quá trình chuyển mã. Cài đặt này hoạt động theo nguyên tắc 'cố gắng hết sức'': nó sẽ quay lại chuyển mã phần mềm nếu gặp lỗi. VP9 có thể hoạt động hoặc không tùy thuộc vào phần cứng của bạn.", "transcoding_acceleration_nvenc": "NVENC (yêu cầu GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (yêu cầu CPU Intel thế hệ 7 hoặc mới hơn)", "transcoding_acceleration_rkmpp": "RKMPP (chỉ trên các SOC của Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Các codec âm thanh được chấp nhận", - "transcoding_accepted_audio_codecs_description": "Chọn các codec âm thanh không cần phải chuyển mã. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", - "transcoding_accepted_containers": "Các định dạng container được chấp nhận", - "transcoding_accepted_containers_description": "Chọn các định dạng container không cần phải chuyển mã sang MP4. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", + "transcoding_accepted_audio_codecs_description": "Chọn các codec âm thanh không cần phải chuyển mã. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", + "transcoding_accepted_containers": "Các định dạng video được chấp nhận", + "transcoding_accepted_containers_description": "Chọn các định dạng tập tin không cần chuyển đổi sang MP4. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", "transcoding_accepted_video_codecs": "Các codec video được chấp nhận", - "transcoding_accepted_video_codecs_description": "Chọn các codec video không cần phải chuyển mã. Chỉ được sử dụng cho một số chính sách chuyển mã nhất định.", + "transcoding_accepted_video_codecs_description": "Chọn các codec video không cần phải chuyển mã. Chỉ được sử dụng cho một số quy tắc chuyển mã nhất định.", "transcoding_advanced_options_description": "Các tùy chọn mà hầu hết người dùng không cần phải thay đổi", "transcoding_audio_codec": "Codec âm thanh", "transcoding_audio_codec_description": "Opus là tùy chọn chất lượng cao nhất, nhưng có tính tương thích thấp hơn với các thiết bị hoặc phần mềm cũ.", "transcoding_bitrate_description": "Video có bitrate cao hơn hoặc không ở định dạng được chấp nhận", "transcoding_codecs_learn_more": "Để tìm hiểu thêm về thuật ngữ được sử dụng ở đây, hãy tham khảo tài liệu FFmpeg cho codec H.264, codec HEVCcodec VP9.", "transcoding_constant_quality_mode": "Chế độ chất lượng cố định", - "transcoding_constant_quality_mode_description": "ICQ tốt hơn CQP, nhưng một số thiết bị tăng tốc phần cứng không hỗ trợ chế độ này. Cài đặt tùy chọn này sẽ ưu tiên chế độ đã chỉ định khi sử dụng mã hóa dựa trên chất lượng. Bị bỏ qua bởi NVENC vì nó không hỗ trợ ICQ.", + "transcoding_constant_quality_mode_description": "ICQ tốt hơn CQP, nhưng một số thiết bị tăng tốc phần cứng không hỗ trợ chế độ này. Cài đặt tùy chọn này sẽ ưu tiên chế độ được chỉ định khi sử dụng mã hóa dựa trên chất lượng. Bị bỏ qua bởi NVENC vì nó không hỗ trợ ICQ.", "transcoding_constant_rate_factor": "Hệ số tỷ lệ cố định (-crf)", - "transcoding_constant_rate_factor_description": "Mức chất lượng video. Các giá trị điển hình là 23 cho H.264, 28 cho HEVC, 31 cho VP9 và 35 cho AV1. Giá trị thấp hơn thì tốt hơn, nhưng tạo ra các tệp lớn hơn.", + "transcoding_constant_rate_factor_description": "Mức chất lượng video. Các giá trị điển hình là 23 cho H.264, 28 cho HEVC, 31 cho VP9 và 35 cho AV1. Giá trị thấp hơn thì tốt hơn, nhưng tạo ra các tập tin lớn hơn.", "transcoding_disabled_description": "Không chuyển mã bất kỳ video nào, có thể gây lỗi phát lại trên một số thiết bị", "transcoding_hardware_acceleration": "Tăng tốc phần cứng", - "transcoding_hardware_acceleration_description": "Thí nghiệm; nhanh hơn nhiều, nhưng chất lượng thấp hơn với cùng một bitrate", + "transcoding_hardware_acceleration_description": "(Thử nghiệm) nhanh hơn nhiều nhưng sẽ có chất lượng thấp hơn ở cùng bitrate", "transcoding_hardware_decoding": "Giải mã phần cứng", - "transcoding_hardware_decoding_setting_description": "Chỉ áp dụng cho NVENC, QSV và RKMPP. Kích hoạt tăng tốc end-to-end thay vì chỉ tăng tốc mã hóa. Có thể không hoạt động trên tất cả các video.", + "transcoding_hardware_decoding_setting_description": "Cho phép tăng tốc đầu cuối thay vì chỉ tăng tốc mã hóa. Có thể không hoạt động trên tất cả video.", "transcoding_hevc_codec": "Codec HEVC", - "transcoding_max_b_frames": "Số lượng 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ũ. 0 tắt B-frames, trong khi -1 tự động thiết lập giá trị này.", + "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 một bitrate tối đa có thể làm cho kích thước tệp dự đoán hơn với một chi phí nhỏ cho chất lượng. Tại 720p, các giá trị điển hình là 2600k cho VP9 hoặc HEVC, hoặc 4500k cho H.264. Bị vô hiệu hóa nếu thiết lập là 0.", - "transcoding_max_keyframe_interval": "Khoảng thời gian giữa các keyframe tối đa", - "transcoding_max_keyframe_interval_description": "Thiết lập khoảng thời gian tối đa giữa các keyframe. Giá trị thấp hơn làm giảm hiệu quả 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. 0 tự động thiết lập giá trị này.", + "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_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", "transcoding_preferred_hardware_device": "Thiết bị phần cứng ưa thích", "transcoding_preferred_hardware_device_description": "Chỉ áp dụng cho VAAPI và QSV. Thiết lập nút dri được sử dụng cho chuyển mã phần cứng.", "transcoding_preset_preset": "Preset (-preset)", - "transcoding_preset_preset_description": "Tốc độ nén. Các preset chậm hơn tạo ra các tệp nhỏ hơn, và tăng chất lượng khi nhắm đến một bitrate cụ thể. VP9 bỏ qua tốc độ trên `faster`.", - "transcoding_reference_frames": "Khung tham chiếu", - "transcoding_reference_frames_description": "Số lượng khung để tham chiếu khi nén một khung nhất định. Giá trị cao hơn cải thiện hiệu quả nén, nhưng làm chậm mã hóa. 0 tự động thiết lập giá trị này.", + "transcoding_preset_preset_description": "Tốc độ nén. Các preset chậm hơn tạo ra các tập tin nhỏ hơn và cải thiện chất lượng khi mục tiêu là một bitrate cụ thể. VP9 chỉ hỗ trợ các preset từ 'ultrafast' đến 'faster'.", + "transcoding_reference_frames": "Khung hình tham chiếu", + "transcoding_reference_frames_description": "Số lượng khung hình tham chiếu khi nén một khung hình nhất định. Giá trị cao hơn cải thiện hiệu suất nén nhưng làm chậm quá trình mã hóa. Giá trị 0 để tự động thiết lập giá trị này.", "transcoding_required_description": "Chỉ video không ở định dạng được chấp nhận", - "transcoding_settings": "Cài đặt Chuyển mã Video", - "transcoding_settings_description": "Quản lý thông tin về độ phân giải và mã hóa của các tệp video", + "transcoding_settings": "Chuyển mã video", + "transcoding_settings_description": "Quản lý thông tin độ phân giải và mã hóa của các video", "transcoding_target_resolution": "Độ phân giải mục tiêu", - "transcoding_target_resolution_description": "Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian hơn để mã hóa, có kích thước tệp lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", - "transcoding_temporal_aq": "AQ tạm thời", - "transcoding_temporal_aq_description": "Chỉ áp dụng cho NVENC. Tăng chất lượng của các cảnh chi tiết cao, chuyển động thấp. Có thể không tương thích với các thiết bị cũ.", + "transcoding_target_resolution_description": "Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian hơn để mã hóa, có kích thước tập tin lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", + "transcoding_temporal_aq": "Lượng tử hóa thích ứng (Temporal AQ)", + "transcoding_temporal_aq_description": "Chỉ áp dụng cho NVENC. Tăng chất lượng cho các cảnh có nhiều chi tiết và ít chuyển động. Có thể không tương thích với các thiết bị cũ.", "transcoding_threads": "Luồng", - "transcoding_threads_description": "Giá trị cao hơn dẫn đến mã hóa nhanh hơn, nhưng để lại ít không gian hơn cho máy chủ xử lý các tác vụ khác khi hoạt động. Giá trị này không nên vượt quá số lượng lõi CPU. Tối đa hóa việc sử dụng nếu thiết lập là 0.", - "transcoding_tone_mapping": "Đồ họa sắc thái", - "transcoding_tone_mapping_description": "Cố gắng duy trì sự xuất hiện của video HDR khi chuyển đổi sang SDR. Mỗi thuật toán thực hiện các thỏa thuận khác nhau về màu sắc, chi tiết và độ sáng. Hable giữ chi tiết, Mobius giữ màu sắc và Reinhard giữ độ sáng.", - "transcoding_tone_mapping_npl": "Đồ họa sắc thái NPL", - "transcoding_tone_mapping_npl_description": "Màu sắc sẽ được điều chỉnh để trông bình thường với độ sáng của màn hình này. Theo cách trái ngược, giá trị thấp hơn làm tăng độ sáng của video và ngược lại vì nó bù đắp cho độ sáng của màn hình. 0 tự động thiết lập giá trị này.", - "transcoding_transcode_policy": "Chính sách chuyển mã", - "transcoding_transcode_policy_description": "Chính sách khi nào video nên được chuyển mã. Video HDR luôn luôn được chuyển mã (ngoại trừ khi chuyển mã bị tắt).", + "transcoding_threads_description": "Giá trị cao hơn dẫn đến mã hóa nhanh hơn nhưng để lại ít không gian hơn cho máy chủ xử lý các tác vụ khác khi đang hoạt động. Giá trị này không nên vượt quá số lượng lõi CPU. Tối đa hóa sử dụng nếu đặt thành 0.", + "transcoding_tone_mapping": "Ánh Xạ Sắc Thái (Tone-mapping)", + "transcoding_tone_mapping_description": "Cố gắng duy trì chất lượng video tốt nhất khi chuyển đổi từ HDR sang SDR. Mỗi thuật toán có sự đánh đổi khác nhau về màu sắc, chi tiết và độ sáng. Hable giữ chi tiết, Mobius giữ màu sắc và Reinhard giữ độ sáng.", + "transcoding_tone_mapping_npl": "Ánh Xạ Sắc Thái NPL (Tone-mapping NPL)", + "transcoding_tone_mapping_npl_description": "Màu sắc sẽ được điều chỉnh để trông bình thường với độ sáng của màn hình này. Theo cách trái ngược, giá trị thấp hơn sẽ tăng độ sáng của video và ngược lại vì nó bù đắp cho độ sáng của màn hình. Giá trị 0 để tự động thiết lập giá trị này.", + "transcoding_transcode_policy": "Quy tắc chuyển mã", + "transcoding_transcode_policy_description": "Quy tắc khi nào video nên được chuyển mã. Các video HDR luôn được chuyển mã (ngoại trừ khi tính năng chuyển mã bị tắt).", "transcoding_two_pass_encoding": "Mã hóa hai lần", - "transcoding_two_pass_encoding_setting_description": "Chuyển mã trong hai lần để tạo ra video được mã hóa tốt hơn. Khi bitrate tối đa được bật (cần thiết để hoạt động với H.264 và HEVC), chế độ này sử dụng một phạm vi bitrate dựa trên bitrate tối đa và bỏ qua CRF. Đối với VP9, CRF có thể được sử dụng nếu bitrate tối đa bị tắt.", + "transcoding_two_pass_encoding_setting_description": "Chuyển mã hai lần để tạo ra video được mã hóa tốt hơn. Khi bitrate tối đa được bật (bắt buộc để hoạt động với H.264 và HEVC), chế độ này sử dụng một phạm vi bitrate dựa trên bitrate tối đa và bỏ qua CRF. Đối với VP9, CRF có thể được sử dụng nếu bitrate tối đa bị tắt.", "transcoding_video_codec": "Codec Video", - "transcoding_video_codec_description": "VP9 có hiệu suất cao và tương thích với web, nhưng mất nhiều thời gian hơn để chuyển mã. HEVC hoạt động tương tự, nhưng có độ tương thích web thấp hơn. H.264 là tương thích rộng rãi và nhanh chóng để chuyển mã, nhưng tạo ra các tệp lớn hơn nhiều. AV1 là codec hiệu quả nhất nhưng thiếu hỗ trợ trên các thiết bị cũ.", - "trash_enabled_description": "Kích hoạt tính năng Thùng rác", + "transcoding_video_codec_description": "VP9 có hiệu suất cao và tương thích tốt với web, nhưng thời gian chuyển mã lâu hơn. HEVC có hiệu suất tương tự, nhưng tương thích web thấp hơn. H.264 tương thích rộng rãi và chuyển mã nhanh, nhưng tạo ra các tập tin có kích thước lớn. AV1 là codec hiệu quả nhất nhưng không được hỗ trợ trên các thiết bị cũ.", + "trash_enabled_description": "Bật tính năng Thùng rác", "trash_number_of_days": "Số ngày", - "trash_number_of_days_description": "Số ngày giữ các tệp tin trong thùng rác trước khi xóa chúng vĩnh viễn", - "trash_settings": "Cài đặt Thùng rác", + "trash_number_of_days_description": "Số ngày giữ các ảnh trong thùng rác trước khi xóa chúng vĩnh viễn", + "trash_settings": "Thùng rác", "trash_settings_description": "Quản lý cài đặt thùng rác", - "untracked_files": "Các tệp tin không được theo dõi", - "untracked_files_description": "Những tệp tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", - "user_delete_delay": "Tài khoản và các tệp tin của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", + "untracked_files": "Các tập tin không được theo dõi", + "untracked_files_description": "Những tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", + "user_cleanup_job": "Dọn dẹp người dùng", + "user_delete_delay": "Tài khoản và các ảnh của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", "user_delete_delay_settings": "Thời gian xóa", - "user_delete_delay_settings_description": "Số ngày sau khi xóa để xóa vĩnh viễn tài khoản và các tệp tin của người dùng. Công việc xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", - "user_delete_immediately": "Tài khoản và các tệp tin của {user} sẽ được xếp hàng để xóa vĩnh viễn ngay lập tức.", - "user_delete_immediately_checkbox": "Xếp hàng người dùng và các tệp tin để xóa ngay lập tức", - "user_management": "Quản lý Người dùng", + "user_delete_delay_settings_description": "Số ngày chờ xóa để xóa vĩnh viễn tài khoản và các ảnh của người dùng. Tác vụ xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", + "user_delete_immediately": "Tài khoản và các ảnh của {user} sẽ được xếp hàng để xóa vĩnh viễn ngay lập tức.", + "user_delete_immediately_checkbox": "Xếp hàng người dùng và các ảnh để xóa ngay lập tức", + "user_management": "Quản lý người dùng", "user_password_has_been_reset": "Mật khẩu của người dùng đã được đặt lại:", - "user_password_reset_description": "Vui lòng cung cấp mật khẩu tạm thời cho người dùng và thông báo cho họ rằng họ sẽ cần thay đổi mật khẩu khi đăng nhập lần tiếp theo.", + "user_password_reset_description": "Vui lòng cung cấp mật khẩu tạm thời cho người dùng và thông báo rằng họ cần thay đổi mật khẩu khi đăng nhập lần tiếp theo.", "user_restore_description": "Tài khoản của {user} sẽ được khôi phục.", - "user_restore_scheduled_removal": "Khôi phục người dùng - xóa dự kiến vào {date, date, long}", - "user_settings": "Cài đặt Người dùng", + "user_restore_scheduled_removal": "Khôi phục người dùng - đã lên lịch xóa vào {date, date, long}", + "user_settings": "Người dùng", "user_settings_description": "Quản lý cài đặt người dùng", "user_successfully_removed": "Người dùng {email} đã được xóa thành công.", - "version_check_enabled_description": "Bật yêu cầu định kỳ đến GitHub để kiểm tra các bản phát hành mới", - "version_check_settings": "Kiểm tra Phiên bản", + "version_check_enabled_description": "Bật kiểm tra phiên bản", + "version_check_implications": "Tính năng kiểm tra phiên bản yêu cầu kết nối thường xuyên đến github.com", + "version_check_settings": "Kiểm tra phiên bản", "version_check_settings_description": "Bật/tắt thông báo phiên bản mới", - "video_conversion_job": "Chuyển đổi video", - "video_conversion_job_description": "Chuyển đổi video để tương thích rộng rãi hơn với các trình duyệt và thiết bị" + "video_conversion_job": "Chuyển mã video", + "video_conversion_job_description": "Chuyển đổi định dạng video để tương thích rộng rãi hơn với trình duyệt và thiết bị" }, "admin_email": "Email Quản trị viên", "admin_password": "Mật khẩu Quản trị viên", "administration": "Quản trị", "advanced": "Nâng cao", - "age_months": "Tuổi {months, plural, one {# tháng} other {# tháng}}", - "age_year_months": "Tuổi 1 năm, {months, plural, one {# tháng} other {# tháng}}", - "age_years": "{years, plural, other {Tuổi #}}", - "album_added": "Album đã được thêm", + "age_months": "{months, plural, one {# tháng} other {# tháng}} tuổi", + "age_year_months": "1 tuổi, {months, plural, one {# tháng} other {# tháng}}", + "age_years": "{years, plural, other {# tuổi}}", + "album_added": "Đã thêm album", "album_added_notification_setting_description": "Nhận thông báo qua email khi bạn được thêm vào một album chia sẻ", - "album_cover_updated": "Bìa album đã được cập nhật", - "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?\nNếu album này đang được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", - "album_info_updated": "Thông tin album đã được cập nhật", + "album_cover_updated": "Đã cập nhật ảnh bìa album", + "album_delete_confirmation": "Bạn có chắc chắn muốn xóa album {album} không?", + "album_delete_confirmation_description": "Nếu album này được chia sẻ, các người dùng khác sẽ không còn truy cập được nữa.", + "album_info_updated": "Đã cập nhật thông tin album", "album_leave": "Rời album?", "album_leave_confirmation": "Bạn có chắc chắn muốn rời khỏi {album} không?", "album_name": "Tên album", @@ -345,77 +369,80 @@ "album_remove_user": "Xóa người dùng?", "album_remove_user_confirmation": "Bạn có chắc chắn muốn xóa {user} không?", "album_share_no_users": "Có vẻ như bạn đã chia sẻ album này với tất cả người dùng hoặc bạn không có người dùng nào để chia sẻ.", - "album_updated": "Album đã được cập nhật", - "album_updated_setting_description": "Nhận thông báo qua email khi một album chia sẻ có tệp tin mới", - "album_user_left": "Rời khỏi {album}", + "album_updated": "Đã cập nhật album", + "album_updated_setting_description": "Nhận thông báo qua email khi một album chia sẻ có các ảnh mới", + "album_user_left": "Đã rời khỏi {album}", "album_user_removed": "Đã xóa {user}", "album_with_link_access": "Cho phép bất kỳ ai có liên kết xem ảnh và người trong album này.", "albums": "Album", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Album}}", "all": "Tất cả", "all_albums": "Tất cả album", - "all_people": "Tất cả người", + "all_people": "Tất cả mọi người", "all_videos": "Tất cả video", "allow_dark_mode": "Cho phép chế độ tối", "allow_edits": "Cho phép chỉnh sửa", "allow_public_user_to_download": "Cho phép người dùng công khai tải xuống", "allow_public_user_to_upload": "Cho phép người dùng công khai tải lên", + "anti_clockwise": "Xoay trái", "api_key": "Khóa API", "api_key_description": "Giá trị này chỉ được hiển thị một lần. Vui lòng sao chép nó trước khi đóng cửa sổ.", "api_key_empty": "Tên khóa API của bạn không được để trống", "api_keys": "Khóa API", - "app_settings": "Cài đặt Ứng dụng", + "app_settings": "Ứng dụng", "appears_in": "Xuất hiện trong", "archive": "Lưu trữ", - "archive_or_unarchive_photo": "Lưu trữ hoặc gỡ lưu trữ ảnh", - "archive_size": "Kích thước lưu trữ", - "archive_size_description": "Cấu hình kích thước lưu trữ cho các tệp tải xuống (trong GiB)", + "archive_or_unarchive_photo": "Lưu trữ hoặc huỷ lưu trữ ảnh", + "archive_size": "Kích thước gói nén", + "archive_size_description": "Cấu hình kích thước nén cho các tập tin tải xuống (đơn vị GiB)", "archived": "", - "archived_count": "{count, plural, other {Đã lưu trữ #}}", - "are_these_the_same_person": "Có phải đây là cùng một người không?", + "archived_count": "{count, plural, other {Đã lưu trữ # mục}}", + "are_these_the_same_person": "Đây có phải cùng một người không?", "are_you_sure_to_do_this": "Bạn có chắc chắn muốn thực hiện điều này không?", "asset_added_to_album": "Đã thêm vào album", "asset_adding_to_album": "Đang thêm vào album...", - "asset_description_updated": "Mô tả tệp tin đã được cập nhật", - "asset_filename_is_offline": "tệp tin {filename} đang ngoại tuyến", - "asset_has_unassigned_faces": "tệp tin có các khuôn mặt chưa được gán", + "asset_description_updated": "Mô tả ảnh đã được cập nhật", + "asset_filename_is_offline": "Ảnh {filename} đang ngoại tuyến", + "asset_has_unassigned_faces": "Ảnh chưa được gán khuôn mặt", "asset_hashing": "Đang băm...", - "asset_offline": "tệp tin ngoại tuyến", - "asset_offline_description": "tệp tin này đang ngoại tuyến. Immich không thể truy cập vị trí tệp của nó. Vui lòng đảm bảo tệp tin có sẵn và sau đó quét lại thư viện.", + "asset_offline": "Ảnh Ngoại tuyến", + "asset_offline_description": "Tập tin bên ngoài này không còn trên ổ đĩa. Vui lòng liên hệ quản trị viên Immich của bạn để được trợ giúp.", "asset_skipped": "Đã bỏ qua", + "asset_skipped_in_trash": "Trong thùng rác", "asset_uploaded": "Đã tải lên", "asset_uploading": "Đang tải lên...", - "assets": "Các tệp tin", - "assets_added_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_added_to_album_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}} vào album", - "assets_added_to_name_count": "Đã thêm {count, plural, one {# tệp tin} other {# tệp tin}} vào {hasName, select, true {{name}} other {album mới}}", - "assets_count": "{count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_moved_to_trash_count": "Đã chuyển {count, plural, one {# tệp tin} other {# tệp tin}} vào thùng rác", - "assets_permanently_deleted_count": "Đã xóa vĩnh viễn {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_removed_count": "Đã xóa {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_restore_confirmation": "Bạn có chắc chắn muốn khôi phục tất cả các tệp tin đã xóa của bạn không? Bạn không thể hoàn tác hành động này!", - "assets_restored_count": "Đã khôi phục {count, plural, one {# tệp tin} other {# tệp tin}}", - "assets_trashed_count": "Đã đưa {count, plural, one {# tệp tin} other {# tệp tin}} vào thùng rác", - "assets_were_part_of_album_count": "{count, plural, one {tệp tin đã} other {Các tệp tin đã}} là một phần của album", - "authorized_devices": "Thiết bị đã được ủy quyền", + "assets": "Các tập tin", + "assets_added_count": "Đã thêm {count, plural, one {# mục} other {# mục}}", + "assets_added_to_album_count": "Đã thêm {count, plural, one {# mục} other {# mục}} vào album", + "assets_added_to_name_count": "Đã thêm {count, plural, one {# mục} other {# mục}} vào {hasName, select, true {{name}} other {album mới}}", + "assets_count": "{count, plural, one {# mục} other {# mục}}", + "assets_moved_to_trash_count": "Đã chuyển {count, plural, one {# mục} other {# mục}} vào thùng rác", + "assets_permanently_deleted_count": "Đã xóa vĩnh viễn {count, plural, one {# mục} other {# mục}}", + "assets_removed_count": "Đã xóa {count, plural, one {# mục} other {# mục}}", + "assets_restore_confirmation": "Bạn có chắc chắn muốn khôi phục tất cả các mục đã xóa của mình không? Bạn không thể hoàn tác hành động này! Lưu ý rằng không thể khôi phục các ảnh ngoại tuyến theo cách này.", + "assets_restored_count": "Đã khôi phục {count, plural, one {# mục} other {# mục}}", + "assets_trashed_count": "Đã chuyển {count, plural, one {# mục} other {# mục}} vào thùng rác", + "assets_were_part_of_album_count": "{count, plural, one {Mục đã} other {Các mục đã}} có trong album", + "authorized_devices": "Thiết bị được ủy quyền", "back": "Quay lại", "back_close_deselect": "Quay lại, đóng, hoặc bỏ chọn", "backward": "Lùi lại", "birthdate_saved": "Ngày sinh đã được lưu thành công", "birthdate_set_description": "Ngày sinh được sử dụng để tính tuổi của người này tại thời điểm chụp ảnh.", "blurred_background": "Nền mờ", - "build": "Build", - "build_image": "Build Image", - "bulk_delete_duplicates_confirmation": "Bạn có chắc chắn muốn xóa hàng loạt {count, plural, one {# tệp tin trùng lặp} other {# tệp tin trùng lặp}} không? Điều này sẽ giữ lại tệp tin lớn nhất của mỗi nhóm và xóa vĩnh viễn tất cả các bản sao trùng lặp khác. Bạn không thể hoàn tác hành động này!", - "bulk_keep_duplicates_confirmation": "Bạn có chắc chắn muốn giữ lại {count, plural, one {# tệp tin trùng lặp} other {# tệp tin trùng lặp}} không? Điều này sẽ giải quyết tất cả các nhóm trùng lặp mà không xóa bất kỳ thứ gì.", - "bulk_trash_duplicates_confirmation": "Bạn có chắc chắn muốn đưa {count, plural, one {# tệp tin trùng lặp} other {# tệp tin trùng lặp}} vào thùng rác không? Điều này sẽ giữ lại tệp tin lớn nhất của mỗi nhóm và đưa tất cả các bản sao trùng lặp khác vào thùng rác.", + "bugs_and_feature_requests": "Lỗi & Yêu cầu tính năng", + "build": "Dựng", + "build_image": "Bản dựng", + "bulk_delete_duplicates_confirmation": "Bạn có chắc chắn muốn xóa hàng loạt {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} không? Điều này sẽ giữ lại ảnh chất lượng nhất của mỗi nhóm và xóa vĩnh viễn tất cả các bản trùng lặp khác. Bạn không thể hoàn tác hành động này!", + "bulk_keep_duplicates_confirmation": "Bạn có chắc chắn muốn giữ lại {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} không? Điều này sẽ xử lý tất cả các nhóm ảnh trùng lặp mà không xóa bất kỳ thứ gì.", + "bulk_trash_duplicates_confirmation": "Bạn có chắc chắn muốn đưa {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}} vào thùng rác không? Điều này sẽ giữ lại ảnh chất lượng nhất của mỗi nhóm và đưa tất cả các bản trùng lặp khác vào thùng rác.", "buy": "Mua Immich", "camera": "Máy ảnh", - "camera_brand": "Hãng máy ảnh", - "camera_model": "Mẫu máy ảnh", + "camera_brand": "Thương hiệu máy ảnh", + "camera_model": "Dòng máy ảnh", "cancel": "Hủy", "cancel_search": "Hủy tìm kiếm", - "cannot_merge_people": "Không thể gộp người", + "cannot_merge_people": "Không thể hợp nhất người", "cannot_undo_this_action": "Bạn không thể hoàn tác hành động này!", "cannot_update_the_description": "Không thể cập nhật mô tả", "cant_apply_changes": "", @@ -424,26 +451,28 @@ "cant_search_places": "", "change_date": "Thay đổi ngày", "change_expiration_time": "Thay đổi thời gian hết hạn", - "change_location": "Thay đổi địa điểm", + "change_location": "Thay đổi vị trí", "change_name": "Thay đổi tên", "change_name_successfully": "Đã thay đổi tên thành công", "change_password": "Thay đổi mật khẩu", - "change_password_description": "Đâ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 dưới đây.", + "change_password_description": "Đây có thể 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 của bạn. Vui lòng nhập mật khẩu mới bên dưới.", "change_your_password": "Thay đổi mật khẩu của bạn", - "changed_visibility_successfully": "Đã thay đổi quyền hiển thị thành công", + "changed_visibility_successfully": "Đã thay đổi trạng thái hiển thị thành công", "check_all": "Chọn tất cả", "check_logs": "Kiểm tra nhật ký", - "choose_matching_people_to_merge": "Chọn những người trùng khớp để gộp", + "choose_matching_people_to_merge": "Chọn những người trùng khớp để hợp nhất", "city": "Thành phố", "clear": "Xóa", "clear_all": "Xóa tất cả", "clear_all_recent_searches": "Xóa tất cả tìm kiếm gần đây", "clear_message": "Xóa tin nhắn", "clear_value": "Xóa giá trị", + "clockwise": "Xoay phải", "close": "Đóng", "collapse": "Thu gọn", "collapse_all": "Thu gọn tất cả", - "color_theme": "Giao diện màu", + "color": "Màu", + "color_theme": "Chủ đề màu sắc", "comment_deleted": "Bình luận đã bị xóa", "comment_options": "Tùy chọn bình luận", "comments_and_likes": "Bình luận & lượt thích", @@ -457,8 +486,8 @@ "continue": "Tiếp tục", "copied_image_to_clipboard": "Đã sao chép hình ảnh vào clipboard.", "copied_to_clipboard": "Đã sao chép vào clipboard!", - "copy_error": "Lỗi sao chép", - "copy_file_path": "Sao chép đường dẫn tệp", + "copy_error": "Sao chép lỗi", + "copy_file_path": "Sao chép đường dẫn tập tin", "copy_image": "Sao chép hình ảnh", "copy_link": "Sao chép liên kết", "copy_link_to_clipboard": "Sao chép liên kết vào clipboard", @@ -472,15 +501,17 @@ "create_library": "Tạo thư viện", "create_link": "Tạo liên kết", "create_link_to_share": "Tạo liên kết để chia sẻ", - "create_link_to_share_description": "Cho phép bất kỳ ai có liên kết xem ảnh đã chọn", + "create_link_to_share_description": "Cho phép bất kỳ ai có liên kết xem các ảnh đã chọn", "create_new_person": "Tạo người mới", - "create_new_person_hint": "Gán các tệp tin đã chọn cho một người mới", + "create_new_person_hint": "Gán các ảnh đã chọn cho một người mới", "create_new_user": "Tạo người dùng mới", + "create_tag": "Tạo thẻ", + "create_tag_description": "Tạo thẻ mới. Với các thẻ lồng nhau, vui lòng nhập đường dẫn đầy đủ của thẻ bao gồm dấu gạch chéo.", "create_user": "Tạo người dùng", "created": "Đã tạo", "current_device": "Thiết bị hiện tại", - "custom_locale": "Định dạng địa phương tùy chỉnh", - "custom_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ và khu vực", + "custom_locale": "Ngôn ngữ và khu vực tùy chỉnh", + "custom_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ và khu vực", "dark": "Tối", "date_after": "Ngày sau", "date_and_time": "Ngày và giờ", @@ -488,41 +519,48 @@ "date_of_birth_saved": "Ngày sinh đã được lưu thành công", "date_range": "Khoảng thời gian", "day": "Ngày", - "deduplicate_all": "Xóa trùng lặp tất cả", - "default_locale": "Ngôn ngữ mặc định", - "default_locale_description": "Định dạng ngày tháng và số dựa trên ngôn ngữ của trình duyệt của bạn", + "deduplicate_all": "Xóa tất cả mục trùng lặp", + "default_locale": "Ngôn ngữ và khu vực mặc định", + "default_locale_description": "Định dạng ngày và số dựa trên ngôn ngữ trình duyệt của bạn", "delete": "Xóa", "delete_album": "Xóa album", "delete_api_key_prompt": "Bạn có chắc chắn muốn xóa khóa API này không?", - "delete_duplicates_confirmation": "Bạn có chắc chắn muốn xóa vĩnh viễn các bản sao này không?", + "delete_duplicates_confirmation": "Bạn có chắc chắn muốn xóa vĩnh viễn các bản trùng lặp này không?", "delete_key": "Xóa khóa", - "delete_library": "Xóa thư viện", + "delete_library": "Xóa Thư viện", "delete_link": "Xóa liên kết", "delete_shared_link": "Xóa liên kết chia sẻ", + "delete_tag": "Xóa thẻ", + "delete_tag_confirmation_prompt": "Bạn có chắc chắn muốn xóa thẻ {tagName} không?", "delete_user": "Xóa người dùng", "deleted_shared_link": "Đã xóa liên kết chia sẻ", + "deletes_missing_assets": "Xóa các ảnh không còn tồn tại trên ổ đĩa", "description": "Mô tả", "details": "Chi tiết", "direction": "Hướng", "disabled": "Tắt", "disallow_edits": "Không cho phép chỉnh sửa", - "discover": "Khám phá", + "discord": "Discord", + "discover": "Tìm", "dismiss_all_errors": "Bỏ qua tất cả lỗi", "dismiss_error": "Bỏ qua lỗi", "display_options": "Tùy chọn hiển thị", "display_order": "Thứ tự hiển thị", "display_original_photos": "Hiển thị ảnh gốc", - "display_original_photos_setting_description": "Ưu tiên hiển thị ảnh gốc khi xem tệp tin thay vì ảnh thu nhỏ khi tệp tin gốc tương thích với web. Điều này có thể dẫn đến tốc độ hiển thị ảnh chậm hơn.", + "display_original_photos_setting_description": "Ưu tiên hiển thị ảnh gốc khi xem ảnh thay vì hình thu nhỏ khi ảnh gốc tương thích với web. Điều này có thể dẫn đến tốc độ hiển thị ảnh chậm hơn.", "do_not_show_again": "Không hiển thị thông báo này nữa", - "done": "Hoàn thành", + "documentation": "Tài liệu", + "done": "Xong", "download": "Tải xuống", + "download_include_embedded_motion_videos": "Các video nhúng", + "download_include_embedded_motion_videos_description": "Gồm các video được nhúng trong ảnh chuyển động thành một tập tin riêng", "download_settings": "Tải xuống", - "download_settings_description": "Quản lý các cài đặt liên quan đến việc tải xuống tệp tin", + "download_settings_description": "Quản lý cài đặt liên quan đến việc tải ảnh xuống", "downloading": "Đang tải xuống", - "downloading_asset_filename": "Đang tải xuống tệp tin {filename}", - "drop_files_to_upload": "Kéo thả các tệp để tải lên", - "duplicates": "Trùng lặp", - "duplicates_description": "Giải quyết mỗi nhóm bằng cách chỉ định cái nào, nếu có, là bản sao", + "downloading_asset_filename": "Đang tải xuống tập tin {filename}", + "drop_files_to_upload": "Kéo thả các tập tin để tải lên", + "duplicates": "Mục trùng lặp", + "duplicates_description": "Xem lại các nhóm ảnh bị nghi ngờ trùng lặp và chọn những mục bạn muốn giữ hoặc xóa", "duration": "Thời gian", "durations": { "days": "", @@ -536,7 +574,7 @@ "edit_avatar": "Chỉnh sửa ảnh đại diện", "edit_date": "Chỉnh sửa ngày", "edit_date_and_time": "Chỉnh sửa ngày và giờ", - "edit_exclusion_pattern": "Chỉnh sửa mẫu loại trừ", + "edit_exclusion_pattern": "Chỉnh sửa quy tắc loại trừ", "edit_faces": "Chỉnh sửa khuôn mặt", "edit_import_path": "Chỉnh sửa đường dẫn nhập", "edit_import_paths": "Chỉnh sửa các đường dẫn nhập", @@ -545,73 +583,78 @@ "edit_location": "Chỉnh sửa vị trí", "edit_name": "Chỉnh sửa tên", "edit_people": "Chỉnh sửa người", + "edit_tag": "Chỉnh sửa thẻ", "edit_title": "Chỉnh sửa tiêu đề", "edit_user": "Chỉnh sửa người dùng", "edited": "Đã chỉnh sửa", - "editor": "", + "editor": "Trình chỉnh sửa", + "editor_close_without_save_prompt": "Những thay đổi sẽ không được lưu", + "editor_close_without_save_title": "Đóng trình chỉnh sửa?", + "editor_crop_tool_h2_aspect_ratios": "Tỷ lệ khung hình", + "editor_crop_tool_h2_rotation": "Xoay", "email": "Email", "empty": "", "empty_album": "", - "empty_trash": "Làm trống thùng rác", - "empty_trash_confirmation": "Bạn có chắc chắn muốn làm trống thùng rác không? Điều này sẽ xóa tất cả các tệp tin trong thùng rác vĩnh viễn khỏi Immich.\nBạn không thể hoàn tác hành động này!", - "enable": "Kích hoạt", - "enabled": "Đã kích hoạt", + "empty_trash": "Dọn sạch thùng rác", + "empty_trash_confirmation": "Bạn có chắc chắn muốn dọn sạch thùng rác không? Điều này sẽ xóa vĩnh viễn tất cả các mục trong thùng rác khỏi Immich.\nBạn không thể hoàn tác hành động này!", + "enable": "Bật", + "enabled": "Đã bật", "end_date": "Ngày kết thúc", "error": "Lỗi", "error_loading_image": "Lỗi tải ảnh", "error_title": "Lỗi - Có điều gì đó không đúng", "errors": { - "cannot_navigate_next_asset": "Không thể điều hướng đến tệp tin tiếp theo", - "cannot_navigate_previous_asset": "Không thể điều hướng đến tệp tin trước đó", + "cannot_navigate_next_asset": "Không thể điều hướng đến ảnh tiếp theo", + "cannot_navigate_previous_asset": "Không thể điều hướng đến ảnh trước đó", "cant_apply_changes": "Không thể áp dụng thay đổi", "cant_change_activity": "Không thể {enabled, select, true {disable} other {enable}} hoạt động", - "cant_change_asset_favorite": "Không thể thay đổi yêu thích cho tệp tin", - "cant_change_metadata_assets_count": "Không thể thay đổi siêu dữ liệu của {count, plural, one {# tệp tin} other {# tệp tin}}", - "cant_get_faces": "Không thể lấy khuôn mặt", - "cant_get_number_of_comments": "Không thể lấy số lượng bình luận", + "cant_change_asset_favorite": "Không thể thay đổi yêu thích cho ảnh", + "cant_change_metadata_assets_count": "Không thể thay đổi siêu dữ liệu của {count, plural, one {# mục} other {# mục}}", + "cant_get_faces": "Không thể tải khuôn mặt", + "cant_get_number_of_comments": "Không thể tải số lượng bình luận", "cant_search_people": "Không thể tìm kiếm người", "cant_search_places": "Không thể tìm kiếm địa điểm", - "cleared_jobs": "Đã xóa các công việc cho: {job}", - "error_adding_assets_to_album": "Lỗi khi thêm tệp tin vào album", + "cleared_jobs": "Đã xoá các tác vụ: {job}", + "error_adding_assets_to_album": "Lỗi khi thêm ảnh vào album", "error_adding_users_to_album": "Lỗi khi thêm người dùng vào album", "error_deleting_shared_user": "Lỗi khi xóa người dùng chia sẻ", "error_downloading": "Lỗi khi tải xuống {filename}", "error_hiding_buy_button": "Lỗi khi ẩn nút mua", - "error_removing_assets_from_album": "Lỗi khi xóa tệp tin khỏi album, kiểm tra bảng điều khiển để biết thêm chi tiết", - "error_selecting_all_assets": "Lỗi khi chọn tất cả các tệp tin", - "exclusion_pattern_already_exists": "Mẫu loại trừ này đã tồn tại.", - "failed_job_command": "Lệnh {command} không thành công cho công việc: {job}", + "error_removing_assets_from_album": "Lỗi khi xóa ảnh khỏi album, kiểm tra bảng điều khiển để biết thêm chi tiết", + "error_selecting_all_assets": "Lỗi khi chọn tất cả ảnh", + "exclusion_pattern_already_exists": "Quy tắc loại trừ này đã tồn tại.", + "failed_job_command": "Lệnh {command} không thành công cho tác vụ: {job}", "failed_to_create_album": "Không thể tạo album", "failed_to_create_shared_link": "Không thể tạo liên kết chia sẻ", "failed_to_edit_shared_link": "Không thể chỉnh sửa liên kết chia sẻ", - "failed_to_get_people": "Không thể lấy người", - "failed_to_load_asset": "Không thể tải tệp tin", - "failed_to_load_assets": "Không thể tải các tệp tin", + "failed_to_get_people": "Không thể tải người", + "failed_to_load_asset": "Không thể tải ảnh", + "failed_to_load_assets": "Không thể tải các ảnh", "failed_to_load_people": "Không thể tải người", "failed_to_remove_product_key": "Không thể xóa khóa sản phẩm", - "failed_to_stack_assets": "Không thể xếp chồng các tệp tin", - "failed_to_unstack_assets": "Không thể gỡ xếp các tệp tin", + "failed_to_stack_assets": "Không thể nhóm các ảnh", + "failed_to_unstack_assets": "Không thể huỷ xếp nhóm các ảnh", "import_path_already_exists": "Đường dẫn nhập này đã tồn tại.", "incorrect_email_or_password": "Email hoặc mật khẩu không chính xác", "paths_validation_failed": "{paths, plural, one {# đường dẫn} other {# đường dẫn}} không hợp lệ", - "profile_picture_transparent_pixels": "Hình ảnh hồ sơ không được có điểm ảnh trong suốt. Vui lòng phóng to và/hoặc di chuyển hình ảnh.", - "quota_higher_than_disk_size": "Bạn đã đặt hạn ngạch cao hơn kích thước đĩa", - "repair_unable_to_check_items": "Không thể kiểm tra {count, select, one {mục} other {các mục}}", + "profile_picture_transparent_pixels": "Ảnh đại diện không thể có điểm ảnh trong suốt. Vui lòng phóng to và/hoặc di chuyển hình ảnh.", + "quota_higher_than_disk_size": "Bạn đã đặt hạn mức cao hơn kích thước ổ đĩa", + "repair_unable_to_check_items": "Không thể kiểm tra {count, select, one {mục} other {mục}}", "unable_to_add_album_users": "Không thể thêm người dùng vào album", - "unable_to_add_assets_to_shared_link": "Không thể thêm tệp tin vào liên kết chia sẻ", + "unable_to_add_assets_to_shared_link": "Không thể thêm ảnh vào liên kết chia sẻ", "unable_to_add_comment": "Không thể thêm bình luận", - "unable_to_add_exclusion_pattern": "Không thể thêm mẫu loại trừ", + "unable_to_add_exclusion_pattern": "Không thể thêm quy tắc loại trừ", "unable_to_add_import_path": "Không thể thêm đường dẫn nhập", - "unable_to_add_partners": "Không thể thêm đối tác", - "unable_to_add_remove_archive": "Không thể {archived, select, true {xóa tệp tin khỏi} other {thêm tệp tin vào}} kho lưu trữ", - "unable_to_add_remove_favorites": "Không thể {favorite, select, true {thêm tệp tin vào} other {xóa tệp tin khỏi}} danh sách yêu thích", - "unable_to_archive_unarchive": "Không thể {archived, select, true {lưu trữ} other {gỡ lưu trữ}}", + "unable_to_add_partners": "Không thể thêm người thân", + "unable_to_add_remove_archive": "Không thể {archived, select, true {xóa ảnh khỏi} other {thêm ảnh vào}} Kho lưu trữ", + "unable_to_add_remove_favorites": "Không thể {favorite, select, true {thêm ảnh vào} other {xóa ảnh khỏi}} Mục yêu thích", + "unable_to_archive_unarchive": "Không thể {archived, select, true {lưu trữ} other {huỷ lưu trữ}}", "unable_to_change_album_user_role": "Không thể thay đổi vai trò của người dùng album", "unable_to_change_date": "Không thể thay đổi ngày", - "unable_to_change_favorite": "Không thể thay đổi yêu thích cho tệp tin", - "unable_to_change_location": "Không thể thay đổi địa điểm", + "unable_to_change_favorite": "Không thể thay đổi yêu thích cho ảnh", + "unable_to_change_location": "Không thể thay đổi vị trí", "unable_to_change_password": "Không thể thay đổi mật khẩu", - "unable_to_change_visibility": "Không thể thay đổi quyền truy cập cho {count, plural, one {# người} other {# người}}", + "unable_to_change_visibility": "Không thể thay đổi trạng thái hiển thị cho {count, plural, one {# người} other {# người}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "Không thể hoàn tất đăng nhập OAuth", @@ -623,46 +666,47 @@ "unable_to_create_library": "Không thể tạo thư viện", "unable_to_create_user": "Không thể tạo người dùng", "unable_to_delete_album": "Không thể xóa album", - "unable_to_delete_asset": "Không thể xóa tệp tin", - "unable_to_delete_assets": "Lỗi khi xóa các tệp tin", - "unable_to_delete_exclusion_pattern": "Không thể xóa mẫu loại trừ", + "unable_to_delete_asset": "Không thể xóa ảnh", + "unable_to_delete_assets": "Lỗi khi xóa các ảnh", + "unable_to_delete_exclusion_pattern": "Không thể xóa quy tắc loại trừ", "unable_to_delete_import_path": "Không thể xóa đường dẫn nhập", "unable_to_delete_shared_link": "Không thể xóa liên kết chia sẻ", "unable_to_delete_user": "Không thể xóa người dùng", - "unable_to_download_files": "Không thể tải xuống tệp tin", - "unable_to_edit_exclusion_pattern": "Không thể chỉnh sửa mẫu loại trừ", + "unable_to_download_files": "Không thể tải xuống tập tin", + "unable_to_edit_exclusion_pattern": "Không thể chỉnh sửa quy tắc loại trừ", "unable_to_edit_import_path": "Không thể chỉnh sửa đường dẫn nhập", - "unable_to_empty_trash": "Không thể làm trống thùng rác", + "unable_to_empty_trash": "Không thể dọn sạch thùng rác", "unable_to_enter_fullscreen": "Không thể vào chế độ toàn màn hình", "unable_to_exit_fullscreen": "Không thể thoát chế độ toàn màn hình", "unable_to_get_comments_number": "Không thể lấy số lượng bình luận", "unable_to_get_shared_link": "Không thể lấy liên kết chia sẻ", "unable_to_hide_person": "Không thể ẩn người", + "unable_to_link_motion_video": "Không thể liên kết video chuyển động", "unable_to_link_oauth_account": "Không thể liên kết tài khoản OAuth", "unable_to_load_album": "Không thể tải album", - "unable_to_load_asset_activity": "Không thể tải hoạt động của tệp tin", + "unable_to_load_asset_activity": "Không thể tải hoạt động của ảnh", "unable_to_load_items": "Không thể tải các mục", "unable_to_load_liked_status": "Không thể tải trạng thái thích", "unable_to_log_out_all_devices": "Không thể đăng xuất khỏi tất cả các thiết bị", "unable_to_log_out_device": "Không thể đăng xuất khỏi thiết bị", "unable_to_login_with_oauth": "Không thể đăng nhập với OAuth", "unable_to_play_video": "Không thể phát video", - "unable_to_reassign_assets_existing_person": "Không thể phân công lại các tệp tin cho {name, select, null {một người đã tồn tại} other {{name}}}", - "unable_to_reassign_assets_new_person": "Không thể phân công lại các tệp tin cho một người mới", + "unable_to_reassign_assets_existing_person": "Không thể gán lại ảnh cho {name, select, null {một người hiện có} other {{name}}}", + "unable_to_reassign_assets_new_person": "Không thể gán lại ảnh cho một người mới", "unable_to_refresh_user": "Không thể làm mới người dùng", "unable_to_remove_album_users": "Không thể xóa người dùng khỏi album", "unable_to_remove_api_key": "Không thể xóa khóa API", - "unable_to_remove_assets_from_shared_link": "Không thể xóa tệp tin khỏi liên kết chia sẻ", + "unable_to_remove_assets_from_shared_link": "Không thể xóa các mục đã chọn khỏi liên kết chia sẻ", "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "Không thể xóa tập tin ngoại tuyến", "unable_to_remove_library": "Không thể xóa thư viện", - "unable_to_remove_offline_files": "Không thể xóa tệp tin ngoại tuyến", - "unable_to_remove_partner": "Không thể xóa đối tác", + "unable_to_remove_partner": "Không thể xóa người thân", "unable_to_remove_reaction": "Không thể xóa phản ứng", "unable_to_remove_user": "", "unable_to_repair_items": "Không thể sửa chữa các mục", "unable_to_reset_password": "Không thể đặt lại mật khẩu", - "unable_to_resolve_duplicate": "Không thể giải quyết trùng lặp", - "unable_to_restore_assets": "Không thể khôi phục các tệp tin", + "unable_to_resolve_duplicate": "Không thể xử lý trùng lặp", + "unable_to_restore_assets": "Không thể khôi phục ảnh", "unable_to_restore_trash": "Không thể khôi phục thùng rác", "unable_to_restore_user": "Không thể khôi phục người dùng", "unable_to_save_album": "Không thể lưu album", @@ -673,19 +717,20 @@ "unable_to_save_settings": "Không thể lưu cài đặt", "unable_to_scan_libraries": "Không thể quét các thư viện", "unable_to_scan_library": "Không thể quét thư viện", - "unable_to_set_feature_photo": "Không thể đặt ảnh đại diện", - "unable_to_set_profile_picture": "Không thể đặt ảnh hồ sơ", - "unable_to_submit_job": "Không thể gửi công việc", - "unable_to_trash_asset": "Không thể chuyển tệp tin vào thùng rác", + "unable_to_set_feature_photo": "Không thể đặt ảnh nổi bật", + "unable_to_set_profile_picture": "Không thể đặt ảnh đại diện", + "unable_to_submit_job": "Không thể gửi tác vụ", + "unable_to_trash_asset": "Không thể chuyển ảnh vào thùng rác", "unable_to_unlink_account": "Không thể hủy liên kết tài khoản", - "unable_to_update_album_cover": "Không thể cập nhật bìa album", + "unable_to_unlink_motion_video": "Không thể hủy liên kết video chuyển động", + "unable_to_update_album_cover": "Không thể cập nhật ảnh bìa album", "unable_to_update_album_info": "Không thể cập nhật thông tin album", "unable_to_update_library": "Không thể cập nhật thư viện", - "unable_to_update_location": "Không thể cập nhật địa điểm", + "unable_to_update_location": "Không thể cập nhật vị trí", "unable_to_update_settings": "Không thể cập nhật cài đặt", "unable_to_update_timeline_display_status": "Không thể cập nhật trạng thái hiển thị dòng thời gian", "unable_to_update_user": "Không thể cập nhật người dùng", - "unable_to_upload_file": "Không thể tải lên tệp tin" + "unable_to_upload_file": "Không thể tải tập tin lên" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -698,32 +743,37 @@ "expired": "Hết hạn", "expires_date": "Hết hạn vào {date}", "explore": "Khám phá", + "explorer": "Khám phá", "export": "Xuất", "export_as_json": "Xuất dưới dạng JSON", - "extension": "Mở rộng", - "external": "Ngoài", - "external_libraries": "Thư viện ngoài", - "face_unassigned": "Chưa gán", + "extension": "Phần mở rộng", + "external": "Bên ngoài", + "external_libraries": "Thư viện bên ngoài", + "face_unassigned": "Chưa được gán", "failed_to_get_people": "", "favorite": "Yêu thích", - "favorite_or_unfavorite_photo": "Đánh dấu hoặc bỏ dấu ảnh yêu thích", - "favorites": "Danh sách yêu thích", + "favorite_or_unfavorite_photo": "Yêu thích hoặc bỏ yêu thích ảnh", + "favorites": "Ảnh yêu thích", "feature": "", - "feature_photo_updated": "Ảnh đặc trưng đã được cập nhật", + "feature_photo_updated": "Đã cập nhật ảnh nổi bật", "featurecollection": "", - "file_name": "Tên tệp", - "file_name_or_extension": "Tên tệp hoặc phần mở rộng", - "filename": "Tên tệp", + "features": "Tính năng", + "features_setting_description": "Quản lý các tính năng ứng dụng", + "file_name": "Tên tập tin", + "file_name_or_extension": "Tên hoặc phần mở rộng tập tin", + "filename": "Tên tập tin", "files": "", - "filetype": "Loại tệp", + "filetype": "Loại tập tin", "filter_people": "Lọc người", "find_them_fast": "Tìm nhanh bằng tên với tìm kiếm", "fix_incorrect_match": "Sửa lỗi trùng khớp không chính xác", - "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tệp thư viện", + "folders": "Thư mục", + "folders_feature_description": "Duyệt ảnh và video theo thư mục trên hệ thống tập tin", + "force_re-scan_library_files": "Yêu cầu quét lại tất cả các tập tin thư viện", "forward": "Tiến về phía trước", "general": "Chung", "get_help": "Nhận trợ giúp", - "getting_started": "Hướng dẫn bắt đầu", + "getting_started": "Bắt đầu", "go_back": "Quay lại", "go_to_search": "Đi đến tìm kiếm", "go_to_share_page": "Đi đến trang chia sẻ", @@ -731,9 +781,9 @@ "group_no": "Không nhóm", "group_owner": "Nhóm theo chủ sở hữu", "group_year": "Nhóm theo năm", - "has_quota": "Có hạn ngạch", + "has_quota": "Có hạn mức", "hi_user": "Chào {name} ({email})", - "hide_all_people": "Ẩn tất cả người", + "hide_all_people": "Ẩn tất cả mọi người", "hide_gallery": "Ẩn thư viện", "hide_named_person": "Ẩn người {name}", "hide_password": "Ẩn mật khẩu", @@ -742,26 +792,26 @@ "host": "Máy chủ", "hour": "Giờ", "image": "Hình ảnh", - "image_alt_text_date": "{isVideo, select, true {Video} other {Hình ảnh}} chụp vào {date}", - "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1} vào {date}", - "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1} và {person2} vào {date}", - "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1}, {person2}, và {person3} vào {date}", - "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", - "image_alt_text_date_place": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} vào {date}", - "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1} vào {date}", - "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1} và {person2} vào {date}", - "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1}, {person2}, và {person3} vào {date}", - "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} chụp tại {city}, {country} với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", + "image_alt_text_date": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp vào {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1} vào {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1} và {person2} vào {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1}, {person2}, và {person3} vào {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} vào {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1} vào {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1} và {person2} vào {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1}, {person2}, và {person3} vào {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Hình ảnh}} được chụp tại {city}, {country} với {person1}, {person2}, và {additionalCount, number} người khác vào {date}", "img": "", "immich_logo": "Logo Immich", "immich_web_interface": "Giao diện web Immich", "import_from_json": "Nhập từ JSON", "import_path": "Đường dẫn nhập", "in_albums": "Trong {count, plural, one {# album} other {# album}}", - "in_archive": "Trong lưu trữ", - "include_archived": "Bao gồm các mục đã lưu trữ", - "include_shared_albums": "Bao gồm các album đã chia sẻ", - "include_shared_partner_assets": "Bao gồm các tài nguyên đối tác đã chia sẻ", + "in_archive": "Trong kho lưu trữ", + "include_archived": "Bao gồm các ảnh lưu trữ", + "include_shared_albums": "Bao gồm các album chia sẻ", + "include_shared_partner_assets": "Bao gồm các ảnh người thân chia sẻ", "individual_share": "Chia sẻ cá nhân", "info": "Thông tin", "interval": { @@ -770,11 +820,11 @@ "night_at_midnight": "Mỗi đêm vào lúc nửa đêm", "night_at_twoam": "Mỗi đêm vào lúc 2 giờ sáng" }, - "invite_people": "Mời người", + "invite_people": "Mời mọi người", "invite_to_album": "Mời vào album", "items_count": "{count, plural, one {# mục} other {# mục}}", "job_settings_description": "", - "jobs": "Công việc", + "jobs": "Tác vụ", "keep": "Giữ", "keep_all": "Giữ tất cả", "keyboard_shortcuts": "Phím tắt", @@ -783,13 +833,14 @@ "last_seen": "Lần cuối nhìn thấy", "latest_version": "Phiên bản mới nhất", "latitude": "Vĩ độ", - "leave": "Rời bỏ", + "leave": "Rời khỏi", "let_others_respond": "Cho phép người khác phản hồi", "level": "Cấp độ", "library": "Thư viện", "library_options": "Tùy chọn thư viện", "light": "Sáng", - "like_deleted": "Thích đã bị xóa", + "like_deleted": "Đã xoá thích", + "link_motion_video": "Liên kết video chuyển động", "link_options": "Tùy chọn liên kết", "link_to_oauth": "Liên kết đến OAuth", "linked_oauth_account": "Tài khoản OAuth đã liên kết", @@ -797,20 +848,21 @@ "loading": "Đang tải", "loading_search_results_failed": "Tải kết quả tìm kiếm không thành công", "log_out": "Đăng xuất", - "log_out_all_devices": "Đăng xuất tất cả thiết bị", - "logged_out_all_devices": "Đã đăng xuất tất cả thiết bị", - "logged_out_device": "Đã đăng xuất thiết bị", + "log_out_all_devices": "Đăng xuất tất cả các thiết bị", + "logged_out_all_devices": "Tất cả các thiết bị đã đăng xuất", + "logged_out_device": "Thiết bị đã đăng xuất", "login": "Đăng nhập", "login_has_been_disabled": "Đăng nhập đã bị vô hiệu hóa.", - "logout_all_device_confirmation": "Bạn có chắc chắn muốn đăng xuất tất cả thiết bị không?", + "logout_all_device_confirmation": "Bạn có chắc chắn muốn đăng xuất tất cả các thiết bị không?", "logout_this_device_confirmation": "Bạn có chắc chắn muốn đăng xuất thiết bị này không?", "longitude": "Kinh độ", "look": "Xem", "loop_videos": "Lặp video", - "loop_videos_description": "Bật để tự động lặp video trong trình xem chi tiết.", + "loop_videos_description": "Bật để video tự động lặp lại trong trình xem chi tiết.", + "main_branch_warning": "Bạn đang dùng phiên bản đang phát triển; chúng tôi khuyên bạn nên dùng phiên bản phát hành!", "make": "Thương hiệu", "manage_shared_links": "Quản lý liên kết chia sẻ", - "manage_sharing_with_partners": "Quản lý chia sẻ với đối tác", + "manage_sharing_with_partners": "Quản lý chia sẻ với người thân", "manage_the_app_settings": "Quản lý cài đặt ứng dụng", "manage_your_account": "Quản lý tài khoản của bạn", "manage_your_api_keys": "Quản lý các khóa API của bạn", @@ -822,21 +874,21 @@ "map_settings": "Cài đặt bản đồ", "matches": "Khớp", "media_type": "Loại phương tiện", - "memories": "Ký ức", - "memories_setting_description": "Quản lý những gì bạn thấy trong ký ức của bạn", - "memory": "Ký ức", - "memory_lane_title": "Ký ức {title}", + "memories": "Kỷ niệm", + "memories_setting_description": "Quản lý những kỷ niệm của bạn", + "memory": "Kỷ niệm", + "memory_lane_title": "Kỷ niệm {title}", "menu": "Menu", - "merge": "Gộp", - "merge_people": "Gộp người", - "merge_people_limit": "Bạn chỉ có thể gộp tối đa 5 khuôn mặt cùng một lúc", - "merge_people_prompt": "Bạn có muốn gộp những người này không? Hành động này không thể hoàn tác.", - "merge_people_successfully": "Gộp người thành công", - "merged_people_count": "Đã gộp {count, plural, one {# người} other {# người}}", + "merge": "Hợp nhất", + "merge_people": "Hợp nhất người", + "merge_people_limit": "Bạn chỉ có thể hợp nhất tối đa 5 khuôn mặt cùng một lúc", + "merge_people_prompt": "Bạn có muốn hợp nhất những người này không? Hành động này không thể hoàn tác.", + "merge_people_successfully": "Hợp nhất người thành công", + "merged_people_count": "Đã hợp nhất {count, plural, one {# người} other {# người}}", "minimize": "Thu nhỏ", "minute": "Phút", "missing": "Thiếu", - "model": "Mẫu", + "model": "Dòng", "month": "Tháng", "more": "Thêm", "moved_to_trash": "Đã chuyển vào thùng rác", @@ -852,17 +904,17 @@ "new_version_available": "CÓ PHIÊN BẢN MỚI", "newest_first": "Mới nhất trước", "next": "Tiếp theo", - "next_memory": "Ký ức tiếp theo", + "next_memory": "Kỷ niệm tiếp theo", "no": "Không", - "no_albums_message": "Tạo album để tổ chức ảnh và video của bạn", + "no_albums_message": "Tạo album để tổ sắp xếp ảnh và video của bạn", "no_albums_with_name_yet": "Có vẻ như bạn chưa có bất kỳ album nào với tên này.", "no_albums_yet": "Có vẻ như bạn chưa có bất kỳ album nào.", - "no_archived_assets_message": "Lưu trữ ảnh và video để ẩn chúng khỏi chế độ xem Ảnh của bạn", - "no_assets_message": "NHẤP VÀO ĐỂ TẢI ẢNH ĐẦU TIÊN CỦA BẠN", - "no_duplicates_found": "Không tìm thấy bản sao trùng lặp.", + "no_archived_assets_message": "Lưu trữ ảnh và video để ẩn chúng khỏi thư viện Ảnh của bạn", + "no_assets_message": "NHẤP VÀO ĐỂ TẢI LÊN ẢNH ĐẦU TIÊN CỦA BẠN", + "no_duplicates_found": "Không tìm thấy các mục trùng lặp.", "no_exif_info_available": "Không có thông tin exif", - "no_explore_results_message": "Tải thêm ảnh để khám phá bộ sưu tập của bạn.", - "no_favorites_message": "Thêm ảnh yêu thích để nhanh chóng tìm thấy những bức ảnh và video tốt nhất của bạn", + "no_explore_results_message": "Tải thêm ảnh lên để khám phá bộ sưu tập của bạn.", + "no_favorites_message": "Thêm ảnh yêu thích để nhanh chóng tìm thấy những bức ảnh và video đẹp nhất của bạn", "no_libraries_message": "Tạo một thư viện bên ngoài để xem ảnh và video của bạn", "no_name": "Không có tên", "no_places": "Không có địa điểm", @@ -870,42 +922,45 @@ "no_results_description": "Thử một từ đồng nghĩa hoặc từ khóa tổng quát hơn", "no_shared_albums_message": "Tạo một album để chia sẻ ảnh và video với mọi người trong mạng của bạn", "not_in_any_album": "Không thuộc album nào", - "note_apply_storage_label_to_previously_uploaded assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho các tệp tin đã tải lên trước đó, hãy chạy", - "note_unlimited_quota": "Lưu ý: Nhập 0 để có hạn ngạch không giới hạn", - "notes": "Ghi chú", + "note_apply_storage_label_to_previously_uploaded assets": "Lưu ý: Để áp dụng Nhãn lưu trữ cho các ảnh đã tải lên trước đó, hãy chạy", + "note_unlimited_quota": "Lưu ý: Nhập 0 để có hạn mức không giới hạn", + "notes": "Lưu ý", "notification_toggle_setting_description": "Bật thông báo qua email", "notifications": "Thông báo", "notifications_setting_description": "Quản lý thông báo", "oauth": "OAuth", + "official_immich_resources": "Tài nguyên chính thức của Immich", "offline": "Ngoại tuyến", "offline_paths": "Đường dẫn ngoại tuyến", "offline_paths_description": "Những kết quả này có thể do việc xóa thủ công các tập tin không phải là một phần của thư viện bên ngoài.", "ok": "Đồng ý", "oldest_first": "Cũ nhất trước", "onboarding": "Hướng dẫn sử dụng", - "onboarding_theme_description": "Chọn chủ đề màu sắc cho instance của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", - "onboarding_welcome_description": "Hãy thiết lập instance của bạn với một số cài đặt chung.", + "onboarding_privacy_description": "Các tính năng (tùy chọn) sau đây phụ thuộc vào các dịch vụ bên ngoài và có thể bị tắt bất kỳ lúc nào trong cài đặt quản trị.", + "onboarding_theme_description": "Chọn chủ đề màu sắc cho tài khoản riêng của bạn. Bạn có thể thay đổi điều này sau trong cài đặt của bạn.", + "onboarding_welcome_description": "Hãy thiết lập tài khoản riêng của bạn với một số cài đặt cơ bản.", "onboarding_welcome_user": "Chào mừng, {user}", "online": "Trực tuyến", "only_favorites": "Chỉ yêu thích", - "only_refreshes_modified_files": "Chỉ làm mới các tập tin đã được chỉnh sửa", + "only_refreshes_modified_files": "Chỉ làm mới các tập tin đã thay đổi", + "open_in_map_view": "Mở trong bản đồ", "open_in_openstreetmap": "Mở trong OpenStreetMap", - "open_the_search_filters": "Mở các bộ lọc tìm kiếm", + "open_the_search_filters": "Mở bộ lọc tìm kiếm", "options": "Tùy chọn", "or": "hoặc", - "organize_your_library": "Tổ chức thư viện của bạn", + "organize_your_library": "Sắp xếp thư viện của bạn", "original": "Gốc", "other": "Khác", "other_devices": "Các thiết bị khác", - "other_variables": "Các biến khác", + "other_variables": "Các tham số khác", "owned": "Sở hữu", "owner": "Chủ sở hữu", - "partner": "Đối tác", + "partner": "Người thân", "partner_can_access": "{partner} có thể truy cập", - "partner_can_access_assets": "Tất cả ảnh và video của bạn ngoại trừ những cái trong mục Đã lưu trữ và Đã xóa", - "partner_can_access_location": "Địa điểm nơi ảnh của bạn được chụp", - "partner_sharing": "Chia sẻ đối tác", - "partners": "Đối tác", + "partner_can_access_assets": "Tất cả ảnh và video của bạn ngoại trừ những ảnh và video trong mục Đã lưu trữ và Đã xóa", + "partner_can_access_location": "Vị trí nơi ảnh của bạn được chụp", + "partner_sharing": "Chia sẻ với người thân", + "partners": "Người thân", "password": "Mật khẩu", "password_does_not_match": "Mật khẩu không khớp", "password_required": "Yêu cầu mật khẩu", @@ -916,144 +971,154 @@ "years": "Cách đây {years, plural, one {năm} other {# năm}}" }, "path": "Đường dẫn", - "pattern": "Mẫu", + "pattern": "Quy tắc", "pause": "Tạm dừng", "pause_memories": "Tạm dừng kỷ niệm", "paused": "Đã tạm dừng", "pending": "Đang chờ xử lý", - "people": "Người", + "people": "Mọi người", "people_edits_count": "Đã chỉnh sửa {count, plural, one {# người} other {# người}}", - "people_sidebar_description": "Hiển thị liên kết đến Người trong thanh bên", + "people_feature_description": "Duyệt ảnh và video được nhóm theo người", + "people_sidebar_description": "Hiển thị mục Mọi người trong thanh bên", "perform_library_tasks": "", "permanent_deletion_warning": "Cảnh báo xóa vĩnh viễn", - "permanent_deletion_warning_setting_description": "Hiển thị cảnh báo khi xóa tệp tin vĩnh viễn", + "permanent_deletion_warning_setting_description": "Hiển thị cảnh báo khi xóa vĩnh viễn ảnh", "permanently_delete": "Xóa vĩnh viễn", - "permanently_delete_assets_count": "Xóa vĩnh viễn {count, plural, one {tệp tin} other {tệp tin}}", - "permanently_delete_assets_prompt": "Bạn có chắc chắn muốn xóa vĩnh viễn {count, plural, one {tệp tin này?} other {các tệp tin # này?}} Điều này cũng sẽ xóa {count, plural, one {nó khỏi} other {chúng khỏi}} album(s).", - "permanently_deleted_asset": "tệp tin đã bị xóa vĩnh viễn", - "permanently_deleted_assets_count": "Đã xóa vĩnh viễn {count, plural, one {# tệp tin} other {# tệp tin}}", - "person": "Người", - "person_hidden": "{name}{hidden, select, true { (ẩn)} other {}}", + "permanently_delete_assets_count": "Xóa vĩnh viễn {count, plural, one {mục} other {mục}}", + "permanently_delete_assets_prompt": "Bạn có chắc chắn muốn xóa vĩnh viễn {count, plural, one {mục này?} other {# mục này?}} Điều này cũng sẽ xóa {count, plural, one {nó khỏi} other {chúng khỏi}} các album.", + "permanently_deleted_asset": "Ảnh đã bị xóa vĩnh viễn", + "permanently_deleted_assets_count": "Đã xóa vĩnh viễn {count, plural, one {# mục} other {# mục}}", + "person": "Mọi người", + "person_hidden": "{name}{hidden, select, true { (đã ẩn)} other {}}", "photo_shared_all_users": "Có vẻ như bạn đã chia sẻ ảnh của mình với tất cả người dùng hoặc bạn không có người dùng nào để chia sẻ.", "photos": "Ảnh", "photos_and_videos": "Ảnh & Video", "photos_count": "{count, plural, one {{count, number} Ảnh} other {{count, number} Ảnh}}", "photos_from_previous_years": "Ảnh từ các năm trước", - "pick_a_location": "Chọn một địa điểm", + "pick_a_location": "Chọn một vị trí", "place": "Địa điểm", "places": "Địa điểm", "play": "Phát", "play_memories": "Phát kỷ niệm", - "play_motion_photo": "Phát ảnh động", + "play_motion_photo": "Phát ảnh chuyển động", "play_or_pause_video": "Phát hoặc tạm dừng video", "point": "", "port": "Cổng", - "preset": "Cài đặt sẵn", + "preset": "Mẫu có sẵn", "preview": "Xem trước", "previous": "Trước", "previous_memory": "Kỷ niệm trước", "previous_or_next_photo": "Ảnh trước hoặc sau", "primary": "Chính", - "profile_image_of_user": "Ảnh hồ sơ của {user}", - "profile_picture_set": "Ảnh hồ sơ đã được đặt.", + "privacy": "Bảo mật", + "profile_image_of_user": "Ảnh đại diệncủa {user}", + "profile_picture_set": "Ảnh đại diện đã được đặt.", "public_album": "Album công khai", "public_share": "Chia sẻ công khai", "purchase_account_info": "Người hỗ trợ", "purchase_activated_subtitle": "Cảm ơn bạn đã hỗ trợ Immich và phần mềm mã nguồn mở", - "purchase_activated_time": "Kích hoạt vào {date, date}", + "purchase_activated_time": "Đã kích hoạt vào {date, date}", "purchase_activated_title": "Khóa của bạn đã được kích hoạt thành công", "purchase_button_activate": "Kích hoạt", "purchase_button_buy": "Mua", "purchase_button_buy_immich": "Mua Immich", "purchase_button_never_show_again": "Không hiển thị lại", "purchase_button_reminder": "Nhắc tôi trong 30 ngày", - "purchase_button_remove_key": "Gỡ khóa", + "purchase_button_remove_key": "Xóa khóa", "purchase_button_select": "Chọn", - "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để có khóa sản phẩm chính xác!", + "purchase_failed_activation": "Kích hoạt thất bại! Vui lòng kiểm tra email của bạn để biết khóa sản phẩm chính xác!", "purchase_individual_description_1": "Dành cho cá nhân", "purchase_individual_description_2": "Trạng thái người hỗ trợ", "purchase_individual_title": "Cá nhân", "purchase_input_suggestion": "Có khóa sản phẩm? Nhập khóa bên dưới", - "purchase_license_subtitle": "Mua Immich để hỗ trợ phát triển dịch vụ liên tục", + "purchase_license_subtitle": "Mua Immich để hỗ trợ sự phát triển liên tục của dịch vụ", "purchase_lifetime_description": "Mua trọn đời", "purchase_option_title": "TÙY CHỌN MUA HÀNG", - "purchase_panel_info_1": "Việc xây dựng Immich tốn nhiều thời gian và công sức, và chúng tôi có các kỹ sư toàn thời gian làm việc để làm cho nó tốt nhất có thể. Sứ mệnh của chúng tôi là phần mềm mã nguồn mở và thực hành kinh doanh đạo đức trở thành nguồn thu nhập bền vững cho các nhà phát triển và tạo ra một hệ sinh thái tôn trọng quyền riêng tư với các lựa chọn thay thế thực sự cho các dịch vụ đám mây khai thác.", + "purchase_panel_info_1": "Việc xây dựng Immich tốn nhiều thời gian và công sức, và chúng tôi có các kỹ sư toàn thời gian làm việc để làm cho nó tốt nhất có thể. Sứ mệnh của chúng tôi là phần mềm mã nguồn mở và các hoạt động kinh doanh có đạo đức trở thành nguồn thu nhập bền vững cho các nhà phát triển, đồng thời tạo ra một hệ sinh thái bảo vệ quyền riêng tư với các lựa chọn thay thế thực sự cho các dịch vụ đám mây lợi dụng người dùng.", "purchase_panel_info_2": "Vì chúng tôi cam kết không thêm các tường thu phí, việc mua này sẽ không cấp cho bạn bất kỳ tính năng bổ sung nào trong Immich. Chúng tôi phụ thuộc vào những người dùng như bạn để hỗ trợ sự phát triển liên tục của Immich.", "purchase_panel_title": "Hỗ trợ dự án", "purchase_per_server": "Mỗi máy chủ", "purchase_per_user": "Mỗi người dùng", - "purchase_remove_product_key": "Gỡ khóa sản phẩm", - "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn gỡ khóa sản phẩm?", - "purchase_remove_server_product_key": "Gỡ khóa sản phẩm máy chủ", - "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn gỡ khóa sản phẩm máy chủ?", + "purchase_remove_product_key": "Xóa khóa sản phẩm", + "purchase_remove_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm?", + "purchase_remove_server_product_key": "Xóa khóa sản phẩm máy chủ", + "purchase_remove_server_product_key_prompt": "Bạn có chắc chắn muốn xoá khóa sản phẩm máy chủ?", "purchase_server_description_1": "Dành cho toàn bộ máy chủ", "purchase_server_description_2": "Trạng thái người hỗ trợ", "purchase_server_title": "Máy chủ", "purchase_settings_server_activated": "Khóa sản phẩm máy chủ được quản lý bởi quản trị viên", "range": "", + "rating": "Xếp hạng sao", + "rating_clear": "Xóa đánh giá", + "rating_count": "{count, plural, one {# sao} other {# sao}}", + "rating_description": "Hiển thị xếp hạng EXIF trong bảng thông tin", "raw": "", "reaction_options": "Tùy chọn phản ứng", - "read_changelog": "Đọc bản thay đổi", + "read_changelog": "Đọc nhật ký thay đổi", "reassign": "Gán lại", - "reassigned_assets_to_existing_person": "Đã gán lại {count, plural, one {# tệp tin} other {# tệp tin}} cho {name, select, null {một người hiện có} other {{name}}}", - "reassigned_assets_to_new_person": "Đã gán lại {count, plural, one {# tệp tin} other {# tệp tin}} cho một người mới", - "reassing_hint": "Gán các tệp tin đã chọn cho một người hiện có", + "reassigned_assets_to_existing_person": "Đã gán lại {count, plural, one {# ảnh} other {# ảnh}} cho {name, select, null {một người hiện có} other {{name}}}", + "reassigned_assets_to_new_person": "Đã gán lại {count, plural, one {# ảnh} other {# ảnh}} cho một người mới", + "reassing_hint": "Gán các ảnh đã chọn cho một người hiện có", "recent": "Gần đây", "recent_searches": "Tìm kiếm gần đây", "refresh": "Làm mới", "refresh_encoded_videos": "Làm mới video đã mã hóa", - "refresh_metadata": "Làm mới dữ liệu siêu tập tin", + "refresh_faces": "Làm mới khuôn mặt", + "refresh_metadata": "Làm mới siêu dữ liệu", "refresh_thumbnails": "Làm mới hình thu nhỏ", "refreshed": "Đã làm mới", - "refreshes_every_file": "Làm mới mọi tệp tin", + "refreshes_every_file": "Đọc lại tất cả tập tin mới và hiện có", "refreshing_encoded_video": "Đang làm mới video đã mã hóa", - "refreshing_metadata": "Đang làm mới dữ liệu siêu tập tin", - "regenerating_thumbnails": "Đang tái tạo hình thu nhỏ", - "remove": "Gỡ bỏ", - "remove_assets_album_confirmation": "Bạn có chắc chắn muốn gỡ bỏ {count, plural, one {# tệp tin} other {# tệp tin}} khỏi album?", - "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn gỡ bỏ {count, plural, one {# tệp tin} other {# tệp tin}} khỏi liên kết chia sẻ này?", - "remove_assets_title": "Gỡ bỏ tệp tin?", - "remove_custom_date_range": "Gỡ bỏ phạm vi ngày tùy chỉnh", - "remove_from_album": "Gỡ bỏ khỏi album", - "remove_from_favorites": "Gỡ bỏ khỏi danh sách yêu thích", - "remove_from_shared_link": "Gỡ bỏ khỏi liên kết chia sẻ", - "remove_offline_files": "Gỡ bỏ tệp tin ngoại tuyến", - "remove_user": "Gỡ bỏ người dùng", - "removed_api_key": "Đã gỡ khóa API: {name}", - "removed_from_archive": "Đã gỡ bỏ khỏi lưu trữ", - "removed_from_favorites": "Đã gỡ bỏ khỏi danh sách yêu thích", - "removed_from_favorites_count": "{count, plural, other {Đã gỡ bỏ #}} khỏi danh sách yêu thích", + "refreshing_faces": "Đang làm mới khuôn mặt", + "refreshing_metadata": "Đang làm mới siêu dữ liệu", + "regenerating_thumbnails": "Đang tạo lại hình thu nhỏ", + "remove": "Xóa", + "remove_assets_album_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi album?", + "remove_assets_shared_link_confirmation": "Bạn có chắc chắn muốn xoá {count, plural, one {# mục} other {# mục}} khỏi liên kết chia sẻ này?", + "remove_assets_title": "Xóa mục?", + "remove_custom_date_range": "Bỏ chọn khoảng ngày tùy chỉnh", + "remove_deleted_assets": "Loại bỏ tập tin ngoại tuyến", + "remove_from_album": "Xóa khỏi album", + "remove_from_favorites": "Xóa khỏi Mục yêu thích", + "remove_from_shared_link": "Xóa khỏi liên kết chia sẻ", + "remove_user": "Xóa người dùng", + "removed_api_key": "Khóa API đã xóa: {name}", + "removed_from_archive": "Đã xoá khỏi Kho lưu trữ", + "removed_from_favorites": "Đã xoá khỏi Mục yêu thích", + "removed_from_favorites_count": "{count, plural, other {Đã xoá #}} khỏi Mục yêu thích", + "removed_tagged_assets": "Đã xóa thẻ khỏi {count, plural, one {# mục} other {# mục}}", "rename": "Đổi tên", "repair": "Sửa chữa", - "repair_no_results_message": "Các tệp không được theo dõi và bị mất sẽ xuất hiện ở đây", - "replace_with_upload": "Thay thế bằng tải lên", + "repair_no_results_message": "Các tập tin không được theo dõi và bị mất sẽ xuất hiện ở đây", + "replace_with_upload": "Thay thế bằng tập tin tải lên", "repository": "Kho lưu trữ", "require_password": "Yêu cầu mật khẩu", - "require_user_to_change_password_on_first_login": "Yêu cầu người dùng thay đổi mật khẩu khi lần đầu đăng nhập", + "require_user_to_change_password_on_first_login": "Yêu cầu người dùng thay đổi mật khẩu ở lần đầu đăng nhập", "reset": "Đặt lại", "reset_password": "Đặt lại mật khẩu", - "reset_people_visibility": "Đặt lại khả năng hiển thị người", + "reset_people_visibility": "Đặt lại trạng thái hiển thị của mọi người", "reset_settings_to_default": "", "reset_to_default": "Đặt lại về mặc định", - "resolve_duplicates": "Giải quyết trùng lặp", - "resolved_all_duplicates": "Đã giải quyết tất cả các bản sao", + "resolve_duplicates": "Xử lý các bản trùng lặp", + "resolved_all_duplicates": "Đã xử lý tất cả các bản trùng lặp", "restore": "Khôi phục", "restore_all": "Khôi phục tất cả", "restore_user": "Khôi phục người dùng", - "restored_asset": "tệp tin đã được khôi phục", + "restored_asset": "Ảnh đã được khôi phục", "resume": "Tiếp tục", "retry_upload": "Thử tải lên lại", - "review_duplicates": "Xem xét các bản sao", + "review_duplicates": "Xem xét các mục trùng lặp", "role": "Vai trò", - "role_editor": "Biên tập viên", + "role_editor": "Người chỉnh sửa", "role_viewer": "Người xem", "save": "Lưu", - "saved_api_key": "API Key đã lưu", + "saved_api_key": "Khoá API đã lưu", "saved_profile": "Hồ sơ đã lưu", "saved_settings": "Cài đặt đã lưu", "say_something": "Nói điều gì đó", "scan_all_libraries": "Quét tất cả thư viện", - "scan_all_library_files": "Quét lại tất cả tập tin thư viện", + "scan_all_library_files": "Quét lại tất cả các tập tin thư viện", + "scan_library": "Quét", "scan_new_library_files": "Quét các tập tin thư viện mới", "scan_settings": "Cài đặt quét", "scanning_for_album": "Đang quét album...", @@ -1061,27 +1126,30 @@ "search_albums": "Tìm kiếm album", "search_by_context": "Tìm kiếm theo ngữ cảnh", "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_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...", - "search_camera_model": "Tìm kiếm mẫu máy ảnh...", + "search_camera_model": "Tìm kiếm dòng máy ảnh...", "search_city": "Tìm kiếm thành phố...", "search_country": "Tìm kiếm quốc gia...", - "search_for_existing_person": "Tìm kiếm người đã tồn tại", + "search_for_existing_person": "Tìm kiếm người hiện có", "search_no_people": "Không có người", "search_no_people_named": "Không có người tên \"{name}\"", + "search_options": "Tùy chọn tìm kiếm", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", - "search_state": "Tìm kiếm tiểu bang...", + "search_settings": "Cài đặt tìm kiếm", + "search_state": "Tìm kiếm tỉnh...", + "search_tags": "Tìm kiếm thẻ...", "search_timezone": "Tìm kiếm múi giờ...", "search_type": "Loại tìm kiếm", "search_your_photos": "Tìm kiếm ảnh của bạn", - "searching_locales": "Đang tìm kiếm địa phương...", + "searching_locales": "Đang tìm kiếm khu vực...", "second": "Giây", - "see_all_people": "Xem tất cả người", - "select_album_cover": "Chọn bìa album", + "see_all_people": "Xem tất cả mọi người", + "select_album_cover": "Chọn ảnh bìa album", "select_all": "Chọn tất cả", - "select_all_duplicates": "Chọn tất cả các bản sao", - "select_avatar_color": "Chọn màu đại diện", + "select_all_duplicates": "Chọn tất cả các bản trùng lặp", + "select_avatar_color": "Chọn màu ảnh đại diện", "select_face": "Chọn khuôn mặt", "select_featured_photo": "Chọn ảnh nổi bật", "select_from_computer": "Chọn từ máy tính", @@ -1089,9 +1157,9 @@ "select_library_owner": "Chọn chủ sở hữu thư viện", "select_new_face": "Chọn khuôn mặt mới", "select_photos": "Chọn ảnh", - "select_trash_all": "Chọn tất cả để bỏ vào thùng rác", + "select_trash_all": "Chọn xoá tất cả", "selected": "Đã chọn", - "selected_count": "{count, plural, other {# đã chọn}}", + "selected_count": "{count, plural, other {Đã chọn # mục}}", "send_message": "Gửi tin nhắn", "send_welcome_email": "Gửi email chào mừng", "server": "", @@ -1100,28 +1168,30 @@ "server_stats": "Thống kê máy chủ", "server_version": "Phiên bản máy chủ", "set": "Đặt", - "set_as_album_cover": "Đặt làm bìa album", + "set_as_album_cover": "Đặt làm ảnh bìa album", "set_as_profile_picture": "Đặt làm ảnh đại diện", "set_date_of_birth": "Đặt ngày sinh", "set_profile_picture": "Đặt ảnh đại diện", "set_slideshow_to_fullscreen": "Đặt trình chiếu ở chế độ toàn màn hình", "settings": "Cài đặt", - "settings_saved": "Cài đặt đã lưu", + "settings_saved": "Đã lưu cài đặt", "share": "Chia sẻ", - "shared": "Đã chia sẻ", - "shared_by": "Chia sẻ bởi", - "shared_by_user": "Chia sẻ bởi {user}", - "shared_by_you": "Chia sẻ bởi bạn", + "shared": "Đã được chia sẻ", + "shared_by": "Được chia sẻ bởi", + "shared_by_user": "Được chia sẻ bởi {user}", + "shared_by_you": "Được chia sẻ bởi bạn", "shared_from_partner": "Ảnh từ {partner}", - "shared_links": "Liên kết đã chia sẻ", + "shared_link_options": "Tùy chọn liên kết chia sẻ", + "shared_links": "Liên kết chia sẻ", "shared_photos_and_videos_count": "{assetCount, plural, other {# ảnh & video đã chia sẻ.}}", - "shared_with_partner": "Chia sẻ với {partner}", + "shared_with_partner": "Được chia sẻ với {partner}", "sharing": "Chia sẻ", "sharing_enter_password": "Vui lòng nhập mật khẩu để xem trang này.", - "sharing_sidebar_description": "Hiển thị liên kết đến Chia sẻ trên thanh bên", - "shift_to_permanent_delete": "nhấn ⇧ để xóa vĩnh viễn tệp tin", + "sharing_sidebar_description": "Hiển thị mục Chia sẻ trong thanh bên", + "shift_to_permanent_delete": "nhấn ⇧ để xóa vĩnh viễn ảnh", "show_album_options": "Hiển thị tùy chọn album", - "show_all_people": "Hiển thị tất cả người", + "show_albums": "Hiển thị album", + "show_all_people": "Hiển thị tất cả mọi người", "show_and_hide_people": "Hiển thị & ẩn người", "show_file_location": "Hiển thị vị trí tập tin", "show_gallery": "Hiển thị thư viện ảnh", @@ -1129,19 +1199,24 @@ "show_in_timeline": "Hiển thị trên dòng thời gian", "show_in_timeline_setting_description": "Hiển thị ảnh và video từ người dùng này trong dòng thời gian của bạn", "show_keyboard_shortcuts": "Hiển thị phím tắt", - "show_metadata": "Hiển thị metadata", + "show_metadata": "Hiển thị siêu dữ liệu", "show_or_hide_info": "Hiển thị hoặc ẩn thông tin", "show_password": "Hiển thị mật khẩu", "show_person_options": "Hiển thị tùy chọn người", "show_progress_bar": "Hiển thị thanh tiến trình", "show_search_options": "Hiển thị tùy chọn tìm kiếm", + "show_slideshow_transition": "Hiển thị hiệu ứng chuyển tiếp", "show_supporter_badge": "Huy hiệu người ủng hộ", "show_supporter_badge_description": "Hiển thị huy hiệu người ủng hộ", "shuffle": "Xáo trộn", + "sidebar": "Thanh bên", + "sidebar_display_description": "Hiển thị liên kết đến chế độ xem trong thanh bên", "sign_out": "Đăng xuất", "sign_up": "Đăng ký", "size": "Kích thước", "skip_to_content": "Bỏ qua nội dung", + "skip_to_folders": "Chuyển đến thư mục", + "skip_to_tags": "Chuyển đến thẻ", "slideshow": "Trình chiếu", "slideshow_settings": "Cài đặt trình chiếu", "sort_albums_by": "Sắp xếp album theo...", @@ -1152,113 +1227,134 @@ "sort_recent": "Ảnh gần đây nhất", "sort_title": "Tiêu đề", "source": "Nguồn", - "stack": "Xếp chồng", - "stack_selected_photos": "Xếp chồng các ảnh đã chọn", - "stacked_assets_count": "Xếp chồng {count, plural, one {# tệp tin} other {# tệp tin}}", - "stacktrace": "Dấu vết ngăn xếp", - "start": "Bắt đầu", + "stack": "Nhóm ảnh", + "stack_duplicates": "Nhóm mục trùng lặp", + "stack_select_one_photo": "Chọn một ảnh chính cho nhóm ảnh", + "stack_selected_photos": "Nhóm các ảnh đã chọn", + "stacked_assets_count": "Đã nhóm {count, plural, one {# mục} other {# mục}}", + "stacktrace": "Thông tin chi tiết lỗi", + "start": "Chạy", "start_date": "Ngày bắt đầu", - "state": "Tiểu bang", + "state": "Tỉnh", "status": "Trạng thái", "stop_motion_photo": "Dừng ảnh chuyển động", "stop_photo_sharing": "Dừng chia sẻ ảnh của bạn?", - "stop_photo_sharing_description": "{partner} sẽ không còn khả năng truy cập ảnh của bạn.", + "stop_photo_sharing_description": "{partner} sẽ không thể truy cập được ảnh của bạn.", "stop_sharing_photos_with_user": "Dừng chia sẻ ảnh của bạn với người dùng này", - "storage": "Không gian lưu trữ", + "storage": "Bộ nhớ", "storage_label": "Nhãn lưu trữ", - "storage_usage": "{used} của {available} đã sử dụng", + "storage_usage": "Đã sử dụng {used} của {available}", "submit": "Gửi", "suggestions": "Gợi ý", "sunrise_on_the_beach": "Bình minh trên bãi biển", - "swap_merge_direction": "Hoán đổi hướng gộp", - "sync": "Đồng bộ hóa", + "support": "Hỗ trợ", + "support_and_feedback": "Hỗ trợ & Góp ý", + "support_third_party_description": "Bản cài đặt Immich của bạn được đóng gói bởi một bên thứ ba. Các sự cố bạn gặp phải có thể do gói đó gây ra, vì vậy vui lòng báo cáo sự cố với họ trước bằng cách sử dụng các liên kết bên dưới.", + "swap_merge_direction": "Đổi hướng hợp nhất", + "sync": "Đồng bộ", + "tag": "Thẻ", + "tag_assets": "Gắn thẻ", + "tag_created": "Đã tạo thẻ: {tag}", + "tag_feature_description": "Duyệt ảnh và video được nhóm theo chủ đề thẻ hợp lý", + "tag_not_found_question": "Không tìm thấy thẻ? Tạo một thẻ mới", + "tag_updated": "Đã cập nhật thẻ: {tag}", + "tagged_assets": "Đã gắn thẻ {count, plural, one {# mục} other {# mục}}", + "tags": "Thẻ", "template": "Mẫu", - "theme": "Giao diện", - "theme_selection": "Chọn giao diện", - "theme_selection_description": "Tự động đặt giao diện sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", - "they_will_be_merged_together": "Chúng sẽ được gộp lại với nhau", - "time_based_memories": "Ký ức dựa trên thời gian", + "theme": "Chủ đề", + "theme_selection": "Chủ đề tổng thể", + "theme_selection_description": "Tự động đặt chủ đề sáng hoặc tối dựa trên tùy chọn hệ thống của trình duyệt của bạn", + "they_will_be_merged_together": "Chúng sẽ được hợp nhất với nhau", + "third_party_resources": "Tài nguyên bên thứ ba", + "time_based_memories": "Kỷ niệm dựa trên thời gian", "timezone": "Múi giờ", "to_archive": "Lưu trữ", "to_change_password": "Đổi mật khẩu", "to_favorite": "Yêu thích", "to_login": "Đăng nhập", - "to_trash": "Vứt vào thùng rác", + "to_parent": "Đến thư mục cha", + "to_root": "Tới thư mục gốc", + "to_trash": "Xóa", "toggle_settings": "Chuyển đổi cài đặt", - "toggle_theme": "Chuyển đổi giao diện", + "toggle_theme": "Chuyển đổi chủ đề tối", "toggle_visibility": "", - "total_usage": "Tổng sử dụng", + "total_usage": "Tổng dung lượng đã sử dụng", "trash": "Thùng rác", - "trash_all": "Vứt tất cả", - "trash_count": "Thùng rác {count, number}", - "trash_delete_asset": "Vứt bỏ/Xóa tệp tin", - "trash_no_results_message": "Ảnh và video đã bị vứt vào thùng rác sẽ xuất hiện ở đây.", - "trashed_items_will_be_permanently_deleted_after": "Các mục đã bị vứt vào thùng rác sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", + "trash_all": "Xóa hết", + "trash_count": "Xóa {count, number} mục", + "trash_delete_asset": "Chuyển vào thùng rác/Xóa vĩnh viễn", + "trash_no_results_message": "Ảnh và video đã bị xoá sẽ hiển thị ở đây.", + "trashed_items_will_be_permanently_deleted_after": "Các mục đã xóa sẽ bị xóa vĩnh viễn sau {days, plural, one {# ngày} other {# ngày}}.", "type": "Loại", - "unarchive": "Khôi phục từ lưu trữ", + "unarchive": "Huỷ lưu trữ", "unarchived": "", - "unarchived_count": "{count, plural, other {Khôi phục #}}", + "unarchived_count": "{count, plural, other {Đã huỷ lưu trữ # mục}}", "unfavorite": "Bỏ yêu thích", "unhide_person": "Hiện người", "unknown": "Không xác định", "unknown_album": "", "unknown_year": "Năm không xác định", "unlimited": "Không giới hạn", - "unlink_oauth": "Ngắt liên kết OAuth", - "unlinked_oauth_account": "Tài khoản OAuth đã ngắt liên kết", - "unnamed_album": "Album không tên", - "unnamed_share": "Chia sẻ không tên", + "unlink_motion_video": "Hủy liên kết video chuyển động", + "unlink_oauth": "Huỷ liên kết OAuth", + "unlinked_oauth_account": "Đã huỷ liên kết tài khoản OAuth", + "unnamed_album": "Album chưa đặt tên", + "unnamed_album_delete_confirmation": "Bạn có chắc chắn muốn xóa album này không?", + "unnamed_share": "Chia sẻ chưa đặt tên", "unsaved_change": "Thay đổi chưa lưu", "unselect_all": "Bỏ chọn tất cả", - "unselect_all_duplicates": "Bỏ chọn tất cả các bản sao", - "unstack": "Gỡ xếp chồng", - "unstacked_assets_count": "Gỡ xếp chồng {count, plural, one {# tệp tin} other {# tệp tin}}", - "untracked_files": "Tập tin không được theo dõi", - "untracked_files_decription": "Các tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các di chuyển không thành công, tải lên bị gián đoạn hoặc bị bỏ lại do lỗi", + "unselect_all_duplicates": "Bỏ chọn tất cả các bản trùng lặp", + "unstack": "Huỷ xếp nhóm", + "unstacked_assets_count": "Đã huỷ xếp nhóm {count, plural, one {# mục} other {# mục}}", + "untracked_files": "Các tập tin không được theo dõi", + "untracked_files_decription": "Các tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của quá trình di chuyển thất bại, tải lên bị gián đoạn hoặc bị bỏ lại do lỗi", "up_next": "Tiếp theo", - "updated_password": "Mật khẩu đã cập nhật", + "updated_password": "Đã cập nhật mật khẩu", "upload": "Tải lên", - "upload_concurrency": "Đồng thời tải lên", - "upload_errors": "Tải lên hoàn tất với {count, plural, one {# lỗi} other {# lỗi}}, làm mới trang để xem các tệp tin tải lên mới.", + "upload_concurrency": "Tải lên đồng thời", + "upload_errors": "Tải lên đã hoàn tất với {count, plural, one {# lỗi} other {# lỗi}}, làm mới trang để xem các ảnh mới tải lên.", "upload_progress": "Còn lại {remaining, number} - Đã xử lý {processed, number}/{total, number}", - "upload_skipped_duplicates": "Bỏ qua {count, plural, one {# tệp tin trùng lặp} other {# tệp tin trùng lặp}}", - "upload_status_duplicates": "Trùng lặp", + "upload_skipped_duplicates": "Đã bỏ qua {count, plural, one {# mục trùng lặp} other {# mục trùng lặp}}", + "upload_status_duplicates": "Mục trùng lặp", "upload_status_errors": "Lỗi", "upload_status_uploaded": "Đã tải lên", - "upload_success": "Tải lên thành công, làm mới trang để xem các tệp tin tải lên mới.", + "upload_success": "Tải lên thành công, làm mới trang để xem các tập tin mới tải lên.", "url": "URL", "usage": "Sử dụng", - "use_custom_date_range": "Sử dụng khoảng thời gian tùy chỉnh thay vì", + "use_custom_date_range": "Sử dụng khoảng thời gian tuỳ chỉnh", "user": "Người dùng", "user_id": "ID người dùng", - "user_liked": "{user} đã thích {type, select, photo {bức ảnh này} video {video này} asset {tệp tin này} other {nó}}", + "user_liked": "{user} đã thích {type, select, photo {ảnh này} video {video này} asset {tập tin này} other {nó}}", "user_purchase_settings": "Mua", - "user_purchase_settings_description": "Quản lý việc mua của bạn", + "user_purchase_settings_description": "Quản lý mục mua của bạn", "user_role_set": "Đặt {user} làm {role}", "user_usage_detail": "Chi tiết sử dụng của người dùng", "username": "Tên người dùng", "users": "Người dùng", "utilities": "Tiện ích", - "validate": "Xác thực", - "variables": "Biến", + "validate": "Xác minh", + "variables": "Các tham số", "version": "Phiên bản", "version_announcement_closing": "Bạn của bạn, Alex", - "version_announcement_message": "Chào bạn, có một phiên bản mới của ứng dụng. Vui lòng dành thời gian để xem ghi chú phát hành và đảm bảo rằng cấu hình docker-compose.yml.env của bạn được cập nhật để tránh bất kỳ cấu hình sai nào, đặc biệt nếu bạn sử dụng WatchTower hoặc bất kỳ cơ chế nào xử lý việc cập nhật ứng dụng của bạn tự động.", + "version_announcement_message": "Chào bạn, có một phiên bản mới của ứng dụng. Vui lòng dành thời gian để xem ghi chú phát hành và đảm bảo rằng cấu hình docker-compose.yml.env của bạn được cập nhật để tránh bất kỳ cấu hình sai nào, đặc biệt nếu bạn sử dụng WatchTower hoặc bất kỳ cơ chế nào tự động cập nhật ứng dụng của bạn.", + "version_history": "Lịch sử phiên bản", + "version_history_item": "Đã cài đặt {version} vào {date}", "video": "Video", - "video_hover_setting": "Phát video khi di chuột qua", - "video_hover_setting_description": "Phát video khi chuột di qua mục. Ngay cả khi bị tắt, phát lại có thể được bắt đầu bằng cách di chuột qua biểu tượng phát.", + "video_hover_setting": "Phát đoạn video xem trước khi di chuột", + "video_hover_setting_description": "Phát đoạn video xem trước khi di chuột qua mục. Ngay cả khi tắt chức năng này, vẫn có thể bắt đầu phát video bằng cách di chuột qua biểu tượng phát.", "videos": "Video", "videos_count": "{count, plural, one {# Video} other {# Video}}", "view": "Xem", "view_album": "Xem Album", "view_all": "Xem tất cả", "view_all_users": "Xem tất cả người dùng", - "view_links": "Xem liên kết", - "view_next_asset": "Xem tệp tin tiếp theo", - "view_previous_asset": "Xem tệp tin trước đó", - "view_stack": "Xem xếp chồng", + "view_in_timeline": "Xem trong dòng thời gian", + "view_links": "Xem các liên kết", + "view_next_asset": "Xem ảnh tiếp theo", + "view_previous_asset": "Xem ảnh trước đó", + "view_stack": "Xem nhóm ảnh", "viewer": "", - "visibility_changed": "Đã thay đổi mức độ hiển thị cho {count, plural, one {# người} other {# người}}", + "visibility_changed": "Đã thay đổi trạng thái hiển thị cho {count, plural, one {# người} other {# người}}", "waiting": "Đang chờ", "warning": "Cảnh báo", "week": "Tuần", @@ -1268,5 +1364,5 @@ "years_ago": "{years, plural, one {# năm} other {# năm}} trước", "yes": "Có", "you_dont_have_any_shared_links": "Bạn không có liên kết chia sẻ nào", - "zoom_image": "Phóng to ảnh" + "zoom_image": "Thu phóng ảnh" } diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json new file mode 100644 index 0000000000..8b1e1e5e15 --- /dev/null +++ b/i18n/zh_Hant.json @@ -0,0 +1,1366 @@ +{ + "about": "關於", + "account": "賬號", + "account_settings": "賬號設定", + "acknowledge": "收到", + "action": "操作", + "actions": "操作", + "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": "加入共享相簿", + "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": "磁碟上找不到此外部圖庫檔案,且已移至垃圾桶。如果檔案在圖庫內被移動,請檢查時間軸中是否有新的相應的檔案。若要還原這份檔案,請確保 Immich 可以寫入下列檔案路徑,並讀取掃描圖庫內容。", + "authentication_settings": "驗證設定", + "authentication_settings_description": "管理密碼、OAuth 與其他驗證設定", + "authentication_settings_disable_all": "確定要停用所有登入方式嗎?這樣會完全無法登入。", + "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", + "background_task_job": "背景任務", + "check_all": "全選", + "cleared_jobs": "已為「{job}」清除作業", + "config_set_by_file": "目前的設定已透過配置文檔調整", + "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", + "confirm_delete_library_assets": "您確定要移除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", + "confirm_email_below": "請在底下輸入 {email} 來確認", + "confirm_reprocess_all_faces": "確定要重新處理所有臉孔嗎?這會清除已命名的人物。", + "confirm_user_password_reset": "您確定要重設 {user} 的密碼嗎?", + "create_job": "建立作業", + "crontab_guru": "", + "disable_login": "停用登入", + "disabled": "已禁用", + "duplicate_detection_job_description": "對檔案執行機器學習來偵測相似圖片。(此功能仰賴智慧搜尋)", + "exclusion_pattern_description": "排除規則讓您在掃描資料庫時忽略特定文件和文件夾。用於當您有不想導入的文件(例如 RAW 文件)或文件夾。", + "external_library_created_at": "外部圖庫(於 {date} 建立)", + "external_library_management": "外部圖庫管理", + "face_detection": "臉孔偵測", + "face_detection_description": "使用機器學習偵測檔案中的臉孔(影片只會偵測縮圖中的臉孔)。選擇「重新整理」會重新處理所有檔案。選擇「重設」會清除目前所有的臉孔資料。選擇「遺失的」會把尚未處理的檔案排入處理佇列。臉孔偵測完成後,會把偵測到的臉孔排入臉部辨識佇列,將其分組到現有的或新的人物中。", + "facial_recognition_job_description": "將偵測到的臉孔依照人物分組。此步驟會在臉孔偵測完成後執行。選擇「重設」會重新分組所有臉孔。選擇「遺失的」會把尚未指定人物的臉孔排入佇列。", + "failed_job_command": "{job} 任務的 {command} 指令執行失敗", + "force_delete_user_warning": "警告:這將立即移除使用者及其資料。操作後無法反悔且移除的檔案無法恢復。", + "forcing_refresh_library_files": "強制重新整理所有圖庫檔案", + "image_format": "格式", + "image_format_description": "WebP 能產生相對於 JPEG 更小的檔案,但編碼速度較慢。", + "image_prefer_embedded_preview": "偏好嵌入的預覽", + "image_prefer_embedded_preview_setting_description": "優先使用 RAW 的嵌入預覧作影像處理。可以提升某些影像的顏色精確度,但嵌入預覧的影像品質依相機而異,且可能壓縮較多。", + "image_prefer_wide_gamut": "偏好廣色域", + "image_prefer_wide_gamut_setting_description": "使用 Display P3 來製作縮圖。這可以更好地保留廣色域圖片的鮮豔度,但在舊版瀏覽器或舊設備上,圖片可能會顯示不同。sRGB 圖片會維持 sRGB 以避免顏色變化。", + "image_preview_description": "除去元資料的中型圖片,在查看單一檔案和機器學習時使用", + "image_preview_format": "預覽格式", + "image_preview_quality_description": "預覽品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。而數值較小可能會影響機器學習品質。", + "image_preview_resolution": "預覽解析度", + "image_preview_resolution_description": "觀賞單張照片及機器學習時用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", + "image_preview_title": "預覽設定", + "image_quality": "品質", + "image_quality_description": "圖片品質從1到100,數值越高代表品質越好但檔案也越大,此選項影響預覽和縮圖圖片。", + "image_resolution": "解析度", + "image_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案較大且可能降低應用程式的響應速度。", + "image_settings": "圖片設定", + "image_settings_description": "管理產生圖片的品質和解析度", + "image_thumbnail_description": "除去元資料的小型縮圖,在查看主時間軸等大量照片時使用", + "image_thumbnail_format": "縮圖格式", + "image_thumbnail_quality_description": "縮圖品質爲 1 ~ 100。數值越大品質越高,但會產生較大的檔案,且可能降低應用程式的響應速度。", + "image_thumbnail_resolution": "縮圖解析度", + "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", + "image_thumbnail_title": "縮圖設定", + "job_concurrency": "{job}並行", + "job_created": "已建立作業", + "job_not_concurrency_safe": "這個任務並行並不安全。", + "job_settings": "作業設定", + "job_settings_description": "管理作業並行", + "job_status": "作業狀態", + "jobs_delayed": "已延後 {jobCount, plural, other {# 項作業}}", + "jobs_failed": "{jobCount, plural, other {# 項}}作業失敗", + "library_created": "已建立圖庫:{library}", + "library_cron_expression": "Cron 運算式", + "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", + "library_cron_expression_presets": "現成的 Cron 運算式", + "library_deleted": "圖庫已刪除", + "library_import_path_description": "選取要載入的資料夾。以掃描資料夾(含子資料夾)內的影像和影片。", + "library_scanning": "定期掃描", + "library_scanning_description": "定期圖庫掃描設定", + "library_scanning_enable_description": "啟用圖庫定期掃描", + "library_settings": "外部圖庫", + "library_settings_description": "管理外部圖庫設定", + "library_tasks_description": "執行圖庫任務", + "library_watching_enable_description": "監控外部圖庫的檔案變化", + "library_watching_settings": "圖庫監控(實驗中)", + "library_watching_settings_description": "自動監控檔案的變化", + "logging_enable_description": "啟用記錄檔", + "logging_level_description": "啟用時的記錄層級。", + "logging_settings": "記錄檔", + "machine_learning_clip_model": "CLIP 模型", + "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", + "machine_learning_duplicate_detection": "重複項目偵測", + "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", + "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", + "machine_learning_duplicate_detection_setting_description": "用 CLIP 向量比對潛在重複", + "machine_learning_enabled": "啟用機器學習", + "machine_learning_enabled_description": "若停用,則無視下方的設定,所有機器學習的功能都將停用。", + "machine_learning_facial_recognition": "臉部辨識", + "machine_learning_facial_recognition_description": "偵測、認出並對圖片中的臉孔分組", + "machine_learning_facial_recognition_model": "人臉辨識模型", + "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_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_smart_search": "智慧搜尋", + "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": "管理日誌設定", + "map_dark_style": "深色樣式", + "map_enable_description": "啟用地圖功能", + "map_gps_settings": "地圖與 GPS 設定", + "map_gps_settings_description": "管理地圖和 GPS(逆向地理編碼)設定", + "map_implications": "地圖功能依賴外部平貼服務(tiles.immich.cloud)", + "map_light_style": "淺色樣式", + "map_manage_reverse_geocoding_settings": "管理逆向地理編碼設定", + "map_reverse_geocoding": "逆向地理編碼", + "map_reverse_geocoding_enable_description": "啟用逆向地理編碼", + "map_reverse_geocoding_settings": "逆向地理編碼設定", + "map_settings": "地圖", + "map_settings_description": "管理地圖設定", + "map_style_description": "地圖主題(style.json)的網址", + "metadata_extraction_job": "擷取元資料", + "metadata_extraction_job_description": "擷取每個檔案的 GPS、臉孔、解析度等元資料資訊", + "metadata_faces_import_setting": "啟用臉孔匯入", + "metadata_faces_import_setting_description": "從圖片的 EXIF 資料和側接檔案匯入臉孔", + "metadata_settings": "元資料設定", + "metadata_settings_description": "管理元資料設定", + "migration_job": "遷移", + "migration_job_description": "將照片和人臉的縮圖遷移到最新的文件夾結構", + "no_paths_added": "未添加路徑", + "no_pattern_added": "未添加pattern", + "note_apply_storage_label_previous_assets": "註:要將儲存標籤用於先前上傳的檔案,請執行", + "note_cannot_be_changed_later": "註:之後就無法更改嘍!", + "note_unlimited_quota": "註:輸入 0 表示不限制配額", + "notification_email_from_address": "寄件地址", + "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", + "notification_email_host_description": "電子郵件伺服器主機(例:smtp.immich.app)", + "notification_email_ignore_certificate_errors": "忽略憑證錯誤", + "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", + "notification_email_password_description": "以電子郵件伺服器驗證身份時的密碼", + "notification_email_port_description": "電子郵件伺服器埠口(如: 25、465 或 587)", + "notification_email_sent_test_email_button": "傳送測試電子郵件並儲存", + "notification_email_setting_description": "發送電子郵件通知的設置", + "notification_email_test_email": "傳送測試電子郵件", + "notification_email_test_email_failed": "無法發送測試電子郵件,請檢查您的設置值", + "notification_email_test_email_sent": "測試電子郵件已發送至 {email}。請檢查您的收件箱。", + "notification_email_username_description": "以電子郵件伺服器驗證身份時的使用者名稱", + "notification_enable_email_notifications": "啟用電子郵件通知", + "notification_settings": "通知", + "notification_settings_description": "管理通知設置,包括電子郵件通知", + "oauth_auto_launch": "自動啟動", + "oauth_auto_launch_description": "導覽至登入頁面後自動進行 OAuth 登入流程", + "oauth_auto_register": "自動註冊", + "oauth_auto_register_description": "使用 OAuth 登錄後自動註冊新用戶", + "oauth_button_text": "按鈕文字", + "oauth_client_id": "客戶端 ID", + "oauth_client_secret": "客戶端密鑰", + "oauth_enable_description": "用 OAuth 登入", + "oauth_issuer_url": "簽發者網址", + "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_scope": "範圍", + "oauth_settings": "OAuth", + "oauth_settings_description": "管理 OAuth 登入設定", + "oauth_settings_more_details": "欲瞭解此功能,請參閱說明書。", + "oauth_signing_algorithm": "簽名算法", + "oauth_storage_label_claim": "儲存標籤宣告", + "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定爲此宣告之值。", + "oauth_storage_quota_claim": "儲存配額宣告", + "oauth_storage_quota_claim_description": "自動將使用者的儲存配額定爲此宣告之值。", + "oauth_storage_quota_default": "預設儲存配額(GiB)", + "oauth_storage_quota_default_description": "未宣告時所使用的配額(單位:GiB)(輸入 0 表示不限制配額)。", + "offline_paths": "失效路徑", + "offline_paths_description": "這些可能是手動刪除非外部圖庫的檔案時所遺留的。", + "password_enable_description": "用電子郵件和密碼登入", + "password_settings": "密碼登入", + "password_settings_description": "管理密碼登入設定", + "paths_validated_successfully": "所有路徑驗證成功", + "person_cleanup_job": "清理人物", + "quota_size_gib": "配額(GiB)", + "refreshing_all_libraries": "正在重新整理所有圖庫", + "registration": "管理者註冊", + "registration_description": "由於您是本系統的首位使用者,因此將您指派爲負責管理本系統的管理者,其他使用者須由您協助建立帳號。", + "removing_deleted_files": "移除離線檔案中", + "repair_all": "全部糾正", + "repair_matched_items": "有 {count, plural, other {# 個項目相符}}", + "repaired_items": "已糾正 {count, plural, other {# 個項目}}", + "require_password_change_on_login": "要求使用者在首次登入時更改密碼", + "reset_settings_to_default": "將設定重設回預設", + "reset_settings_to_recent_saved": "已設回最後儲存的設定", + "scanning_library": "掃描圖庫", + "scanning_library_for_changed_files": "掃描圖庫中變更的檔案", + "scanning_library_for_new_files": "掃描圖庫中的新檔案", + "search_jobs": "搜尋作業…", + "send_welcome_email": "傳送歡迎電子郵件", + "server_external_domain_settings": "外部網域", + "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", + "server_settings": "伺服器", + "server_settings_description": "管理伺服器設定", + "server_welcome_message": "歡迎訊息", + "server_welcome_message_description": "在登入頁面顯示的訊息。", + "sidecar_job": "側接元資料", + "sidecar_job_description": "從檔案系統探索或同步側接(Sidecar)元資料", + "slideshow_duration_description": "每張圖片放映的秒數", + "smart_search_job_description": "對檔案執行機器學習,以利智慧搜尋", + "storage_template_date_time_description": "檔案的創建時戳會用於判斷時間資訊", + "storage_template_date_time_sample": "時間樣式 {date}", + "storage_template_enable_description": "啟用存儲模板引擎", + "storage_template_hash_verification_enabled": "散列函数驗證已啟用", + "storage_template_hash_verification_enabled_description": "啟用散列函数驗證,除非您知道自己正在做的事,否則請勿禁用此功能", + "storage_template_migration": "存儲模板遷移", + "storage_template_migration_description": "將當前的 {template} 應用於先前上傳的檔案", + "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_user_label": "{label} 是使用者的儲存標籤", + "system_settings": "系統設定", + "tag_cleanup_job": "清理標記", + "theme_custom_css_settings": "自訂 CSS", + "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", + "theme_settings": "主題", + "theme_settings_description": "自訂 Immich 的網頁界面", + "these_files_matched_by_checksum": "這些檔案的核對和(Checksum)是相符的", + "thumbnail_generation_job": "產生縮圖", + "thumbnail_generation_job_description": "爲每個檔案產生大、小及模糊縮圖,也爲每位人物產生縮圖", + "transcode_policy_description": "", + "transcoding_acceleration_api": "加速 API", + "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_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": "高於最大位元速率或格式不被支援的影片", + "transcoding_codecs_learn_more": "欲瞭解此處使用的術語,請參閱 FFmpeg 說明書中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", + "transcoding_constant_quality_mode": "恆定質量模式", + "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", + "transcoding_constant_rate_factor": "恆定速率因子(-crf)", + "transcoding_constant_rate_factor_description": "視頻質量級別。典型值為 H.264 的 23、HEVC 的 28、VP9 的 31 和 AV1 的 35。數值越低,質量越高,但會產生較大的文件。", + "transcoding_disabled_description": "不轉碼影片,可能會讓某些客戶端無法正常播放", + "transcoding_hardware_acceleration": "硬體加速", + "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_bitrate": "最大位元速率", + "transcoding_max_bitrate_description": "設置最大比特率可以使文件大小更具可預測性,但會稍微降低質量。在 720p 分辨率下,典型值為 VP9 或 HEVC 的 2600k,或 H.264 的 4500k。設置為 0 則禁用此功能。", + "transcoding_max_keyframe_interval": "最大關鍵幀間隔", + "transcoding_max_keyframe_interval_description": "設置關鍵幀之間的最大幀距。較低的值會降低壓縮效率,但可以改善尋找時間,並可能改善快速運動場景中的質量。0 會自動設置此值。", + "transcoding_optimal_description": "高於目標解析度或格式不被支援的影片", + "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": "參考幀數", + "transcoding_reference_frames_description": "壓縮給定幀時參考的幀數。較高的值可以提高壓縮效率,但會降低編碼速度。0 會自動設置此值。", + "transcoding_required_description": "僅限格式不被支援的影片", + "transcoding_settings": "影片轉碼", + "transcoding_settings_description": "管理影片的解析度和編碼資訊", + "transcoding_target_resolution": "目標解析度", + "transcoding_target_resolution_description": "較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", + "transcoding_temporal_aq": "時間自適應量化(Temporal AQ)", + "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_npl": "色調映射 NPL", + "transcoding_tone_mapping_npl_description": "顏色將調整為在此亮度顯示器上看起來正常。反直觀地,較低的值會增加視頻的亮度,反之亦然,因為它會補償顯示器的亮度。0 會自動設置此值。", + "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 是最有效的編解碼器,但在舊設備上支持度不足。", + "trash_enabled_description": "啟用垃圾桶功能", + "trash_number_of_days": "日數", + "trash_number_of_days_description": "永久刪除之前,將檔案保留在垃圾桶中的日數", + "trash_settings": "垃圾桶", + "trash_settings_description": "管理垃圾桶設定", + "untracked_files": "未被追蹤的檔案", + "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", + "user_cleanup_job": "清理使用者", + "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_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": "啟用版本檢查", + "version_check_implications": "版本檢查功能會定期與 github.com 通訊", + "version_check_settings": "版本檢查", + "version_check_settings_description": "啟用 / 停用新版本通知", + "video_conversion_job": "轉碼影片", + "video_conversion_job_description": "對影片轉碼,相容更多瀏覽器和裝置" + }, + "admin_email": "管理者電子郵件", + "admin_password": "管理者密碼", + "administration": "管理", + "advanced": "進階", + "age_months": "{months, plural, other {# 個月大}}", + "age_year_months": "1 歲,{months, plural, other {# 個月}}", + "age_years": "{years, plural, other {# 歲}}", + "album_added": "加入相簿時", + "album_added_notification_setting_description": "當我被加入共享相簿時,用電子郵件通知我", + "album_cover_updated": "已更新相簿封面", + "album_delete_confirmation": "確定要刪除「{album}」(相簿)嗎?", + "album_delete_confirmation_description": "如果已分享此相簿,其他使用者就無法再存取這本相簿了。", + "album_info_updated": "已更新相簿資訊", + "album_leave": "離開相簿?", + "album_leave_confirmation": "您確定要離開 {album} 嗎?", + "album_name": "相簿名稱", + "album_options": "相簿選項", + "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} 本相簿}}", + "all": "全部", + "all_albums": "所有相簿", + "all_people": "所有人", + "all_videos": "所有視頻", + "allow_dark_mode": "允許深色模式", + "allow_edits": "允許編輯", + "allow_public_user_to_download": "開放給使用者下載", + "allow_public_user_to_upload": "開放讓使用者上傳", + "anti_clockwise": "逆時針", + "api_key": "API 金鑰", + "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)", + "archived": "", + "archived_count": "{count, plural, other {已封存 # 個項目}}", + "are_these_the_same_person": "這也是同一個人嗎?", + "are_you_sure_to_do_this": "您確定要這麼做嗎?", + "asset_added_to_album": "已加入相簿", + "asset_adding_to_album": "加入相簿中…", + "asset_description_updated": "檔案描述已更新", + "asset_filename_is_offline": "檔案 {filename} 離線了", + "asset_has_unassigned_faces": "檔案中有未指定的臉孔", + "asset_hashing": "Hashing中...", + "asset_offline": "檔案離線", + "asset_offline_description": "磁碟中找不到此外部檔案。請向您的 Immich 管理員尋求協助。", + "asset_skipped": "已略過", + "asset_skipped_in_trash": "已丟掉", + "asset_uploaded": "已上傳", + "asset_uploading": "上傳中…", + "assets": "檔案", + "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 {# 個檔案}}", + "assets_moved_to_trash_count": "已將 {count, plural, other {# 個檔案}}丟進垃圾桶", + "assets_permanently_deleted_count": "已永久刪除 {count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_removed_count": "已移除 {count, plural, one {# 個檔案} other {# 個檔案}}", + "assets_restore_confirmation": "確定要還原所有丟掉的檔案嗎?此步驟無法取消喔!註:這無法還原任何離線檔案。", + "assets_restored_count": "已還原 {count, plural, other {# 個檔案}}", + "assets_trashed_count": "已丟掉 {count, plural, other {# 個檔案}}", + "assets_were_part_of_album_count": "{count, plural, one {檔案已} other {檔案已}} 是相冊的一部分", + "authorized_devices": "授權裝置", + "back": "后退", + "back_close_deselect": "返回、關閉及取消選取", + "backward": "倒轉", + "birthdate_saved": "出生日期儲存成功", + "birthdate_set_description": "出生日期會用來計算此人拍照時的歲數。", + "blurred_background": "模糊背景", + "bugs_and_feature_requests": "錯誤及功能請求", + "build": "建置編號", + "build_image": "建置映像", + "bulk_delete_duplicates_confirmation": "您確定要批量刪除 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將保留每組中的最大檔案,並永久刪除所有其他重複項。此操作無法撤銷!", + "bulk_keep_duplicates_confirmation": "您確定要保留 {count, plural, one {# 個重複檔案} other {# 個重複檔案}} 嗎?這將解決所有重複組而不刪除任何內容。", + "bulk_trash_duplicates_confirmation": "確定要一次丟掉 {count, plural, other {# 個重複的檔案}}嗎?這樣每組重複的檔案中,最大的會留下來,其它的會被丟進垃圾桶。", + "buy": "購置 Immich", + "camera": "相機", + "camera_brand": "相機品牌", + "camera_model": "相機型號", + "cancel": "取消", + "cancel_search": "取消搜尋", + "cannot_merge_people": "無法合併人物", + "cannot_undo_this_action": "此步驟無法取消喔!", + "cannot_update_the_description": "無法更新描述", + "cant_apply_changes": "", + "cant_get_faces": "", + "cant_search_people": "", + "cant_search_places": "", + "change_date": "更改日期", + "change_expiration_time": "更改失效期限", + "change_location": "更改位置", + "change_name": "改名", + "change_name_successfully": "改名成功", + "change_password": "更改密碼", + "change_password_description": "這是您第一次登入系統,或您被要求更改密碼。請在下面輸入新密碼。", + "change_your_password": "更改您的密碼", + "changed_visibility_successfully": "已成功更改可見性", + "check_all": "全選", + "check_logs": "檢查日誌", + "choose_matching_people_to_merge": "選擇要合併的匹配人物", + "city": "城市", + "clear": "清空", + "clear_all": "全部清除", + "clear_all_recent_searches": "清除所有最近的搜尋", + "clear_message": "清除訊息", + "clear_value": "清除值", + "clockwise": "順時針", + "close": "關閉", + "collapse": "折疊", + "collapse_all": "全部折疊", + "color": "顏色", + "color_theme": "色彩主題", + "comment_deleted": "評論已刪除", + "comment_options": "評論選項", + "comments_and_likes": "評論與讚好", + "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_link_to_share_description": "允許任何擁有連結的人查看所選的照片", + "create_new_person": "創建新人物", + "create_new_person_hint": "將選定的檔案分配給新人物", + "create_new_user": "建立新使用者", + "create_tag": "建立標記", + "create_tag_description": "建立新的標記。若要建立巢狀標記,請輸入完整的標記路徑(包括正斜線 / )。", + "create_user": "建立使用者", + "created": "建立於", + "current_device": "此裝置", + "custom_locale": "自訂區域", + "custom_locale_description": "依語言和區域設定日期和數字格式", + "dark": "深色", + "date_after": "日期之後", + "date_and_time": "日期與時間", + "date_before": "日期之前", + "date_of_birth_saved": "出生日期儲存成功", + "date_range": "日期範圍", + "day": "日", + "deduplicate_all": "刪除所有重複項目", + "default_locale": "預設區域", + "default_locale_description": "依瀏覽器區域設定日期和數字格式", + "delete": "删除", + "delete_album": "刪除相簿", + "delete_api_key_prompt": "您確定要刪除這個 API Key嗎?", + "delete_duplicates_confirmation": "您確定要永久刪除這些重複項嗎?", + "delete_key": "刪除密鑰", + "delete_library": "刪除圖庫", + "delete_link": "刪除鏈結", + "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": "在網頁與原始檔案相容的情況下,查看檔案時優先顯示原始檔案而非縮圖。這可能會讓照片顯示速度變慢。", + "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": "時長", + "durations": { + "days": "", + "hours": "", + "minutes": "", + "months": "", + "years": "" + }, + "edit": "編輯", + "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_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": "", + "empty_album": "", + "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": "無法{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_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_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_load_asset": "檔案載入失敗", + "failed_to_load_assets": "檔案載入失敗", + "failed_to_load_people": "無法載入人物", + "failed_to_remove_product_key": "無法移除產品密鑰", + "failed_to_stack_assets": "無法堆疊檔案", + "failed_to_unstack_assets": "無法解除堆疊檔案", + "import_path_already_exists": "此匯入路徑已存在。", + "incorrect_email_or_password": "電子郵件或密碼有誤", + "paths_validation_failed": "{paths, plural, one {# 個路徑} other {# 個路徑}} 驗證失敗", + "profile_picture_transparent_pixels": "個人頭像不能有透明像素。請放大並/或移動圖像。", + "quota_higher_than_disk_size": "您定的配額高於磁碟容量", + "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_exclusion_pattern": "無法添加排除模式", + "unable_to_add_import_path": "無法添加匯入路徑", + "unable_to_add_partners": "無法添加夥伴", + "unable_to_add_remove_archive": "無法{archived, select, true {從封存中移除檔案} other {將檔案加入封存}}", + "unable_to_add_remove_favorites": "無法將檔案{favorite, select, true {加入收藏} other {從收藏中移除}}", + "unable_to_archive_unarchive": "無法{archived, select, true {封存} other {取消封存}}", + "unable_to_change_album_user_role": "無法更改相簿使用者的角色", + "unable_to_change_date": "無法更改日期", + "unable_to_change_favorite": "無法更改檔案的收藏狀態", + "unable_to_change_location": "無法更改位置", + "unable_to_change_password": "無法更改密碼", + "unable_to_change_visibility": "無法更改 {count, plural, one {# 位人士} other {# 位人士}} 的可見性", + "unable_to_check_item": "", + "unable_to_check_items": "", + "unable_to_complete_oauth_login": "無法完成 OAuth 登入", + "unable_to_connect": "無法連接", + "unable_to_connect_to_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": "無法載入讚好狀態", + "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 {現有的人員} other {{name}}}", + "unable_to_reassign_assets_new_person": "無法將檔案重新指派給新的人員", + "unable_to_refresh_user": "無法重新整理使用者", + "unable_to_remove_album_users": "無法從相簿中移除使用者", + "unable_to_remove_api_key": "無法移除 API 金鑰", + "unable_to_remove_assets_from_shared_link": "無法從分享鏈結中刪除檔案", + "unable_to_remove_comment": "", + "unable_to_remove_deleted_assets": "無法移除離線檔案", + "unable_to_remove_library": "無法移除資料庫", + "unable_to_remove_partner": "無法移除夥伴", + "unable_to_remove_reaction": "無法移除反應", + "unable_to_remove_user": "", + "unable_to_repair_items": "無法糾正項目", + "unable_to_reset_password": "無法重設密碼", + "unable_to_resolve_duplicate": "無法解決重複項", + "unable_to_restore_assets": "無法還原檔案", + "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": "無法設置特色照片", + "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_timeline_display_status": "無法更新時間軸顯示狀態", + "unable_to_update_user": "無法更新使用者", + "unable_to_upload_file": "無法上傳檔案" + }, + "every_day_at_onepm": "", + "every_night_at_midnight": "", + "every_night_at_twoam": "", + "every_six_hours": "", + "exif": "Exif", + "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_get_people": "", + "favorite": "收藏", + "favorite_or_unfavorite_photo": "收藏或取消收藏照片", + "favorites": "收藏", + "feature": "", + "feature_photo_updated": "特色照片已更新", + "featurecollection": "", + "features": "功能", + "features_setting_description": "管理應用程式功能", + "file_name": "檔名", + "file_name_or_extension": "檔名或副檔名", + "filename": "檔案名稱", + "filetype": "檔案類型", + "filter_people": "篩選人物", + "find_them_fast": "搜尋名稱,快速找人", + "fix_incorrect_match": "修復不相符的", + "folders": "資料夾", + "folders_feature_description": "以資料夾瀏覽檔案系統中的照片和影片", + "force_re-scan_library_files": "強制重新掃描所有圖庫檔案", + "forward": "順序", + "general": "一般", + "get_help": "線上求助", + "getting_started": "開始使用", + "go_back": "返回", + "go_to_search": "前往搜尋", + "go_to_share_page": "前往分享頁面", + "group_albums_by": "相簿分組方式", + "group_no": "無分組", + "group_owner": "按擁有者分組", + "group_year": "按年份分組", + "has_quota": "配額", + "hi_user": "嗨!{name}({email})", + "hide_all_people": "隱藏所有人物", + "hide_gallery": "隱藏畫廊", + "hide_named_person": "隱藏 {name}", + "hide_password": "隱藏密碼", + "hide_person": "隱藏人物", + "hide_unnamed_people": "隱藏未命名人物", + "host": "主機", + "hour": "時", + "image": "圖片", + "image_alt_text_date": "{isVideo, select, true {影片} other {圖片}}拍攝於 {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 一同於 {date} 拍攝", + "image_alt_text_date_2_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1} 和 {person2} 一同於 {date} 拍攝", + "image_alt_text_date_3_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", + "image_alt_text_date_place": "{date}在 {country} - {city} 拍攝的{isVideo, select, true {影片} other {圖片}}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {影片} other {圖片}} 於 {city}、{country},與 {person1} 一同在 {date} 拍攝", + "image_alt_text_date_place_2_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1} 和 {person2} 一同於 {date} 拍攝", + "image_alt_text_date_place_3_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和 {person3} 一同於 {date} 拍攝", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {影片} other {圖片}} 在 {city}、{country},與 {person1}、{person2} 和其他 {additionalCount, number} 人於 {date} 拍攝", + "img": "", + "immich_logo": "Immich 標誌", + "immich_web_interface": "Immich 網頁介面", + "import_from_json": "匯入 JSON", + "import_path": "匯入路徑", + "in_albums": "在 {count, plural, other {# 本相簿}}中", + "in_archive": "已封存", + "include_archived": "包含已封存", + "include_shared_albums": "包含共享相簿", + "include_shared_partner_assets": "包括共享夥伴檔案", + "individual_share": "個別分享", + "info": "資訊", + "interval": { + "day_at_onepm": "每天下午 1 點", + "hours": "每 {hours, plural, other {{hours, number} 小時}}", + "night_at_midnight": "每晚午夜", + "night_at_twoam": "每晚凌晨 2 點" + }, + "invite_people": "邀請人員", + "invite_to_album": "邀請至相簿", + "items_count": "{count, plural, other {# 個項目}}", + "job_settings_description": "", + "jobs": "作業", + "keep": "保留", + "keep_all": "全部保留", + "keyboard_shortcuts": "鍵盤快捷鍵", + "language": "語言", + "language_setting_description": "選擇您的首選語言", + "last_seen": "最後上線", + "latest_version": "最新版本", + "latitude": "緯度", + "leave": "離開", + "let_others_respond": "允許他人回覆", + "level": "等級", + "library": "圖庫", + "library_options": "資料庫選項", + "light": "淺色", + "like_deleted": "已刪除的收藏", + "link_motion_video": "鏈結動態影片", + "link_options": "鏈結選項", + "link_to_oauth": "連接 OAuth", + "linked_oauth_account": "已連接 OAuth 帳號", + "list": "列表", + "loading": "載入中", + "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": "管理與夥伴的分享", + "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": "帶有圖像的地圖標記", + "map_settings": "地圖設定", + "matches": "相符", + "media_type": "媒體類型", + "memories": "回憶", + "memories_setting_description": "管理您的回憶中顯示的內容", + "memory": "回憶", + "memory_lane_title": "回憶長廊{title}", + "menu": "選單", + "merge": "合併", + "merge_people": "合併人物", + "merge_people_limit": "一次最多只能合併 5 張臉孔", + "merge_people_prompt": "您要合併這些人物嗎?此操作無法撤銷。", + "merge_people_successfully": "成功合併人物", + "merged_people_count": "合併了 {count, plural, one {# 位人士} other {# 位人士}}", + "minimize": "最小化", + "minute": "分", + "missing": "遺失的", + "model": "型號", + "month": "月", + "more": "更多", + "moved_to_trash": "已丟進垃圾桶", + "my_albums": "我的相簿", + "name": "名稱", + "name_or_nickname": "名稱或暱稱", + "never": "永不失效", + "new_album": "新相簿", + "new_api_key": "新的 API 金鑰", + "new_password": "新密碼", + "new_person": "新的人物", + "new_user_created": "已建立新使用者", + "new_version_available": "新版本已發布", + "newest_first": "最新優先", + "next": "下一張", + "next_memory": "下一張回憶", + "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_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": "啟用電子郵件通知", + "notifications": "通知", + "notifications_setting_description": "管理通知", + "oauth": "OAuth", + "official_immich_resources": "官方 Immich 資源", + "offline": "離線", + "offline_paths": "失效路徑", + "offline_paths_description": "這些可能是手動刪除非外部圖庫的檔案時所遺留的。", + "ok": "確定", + "oldest_first": "由舊至新", + "onboarding": "入門指南", + "onboarding_privacy_description": "以下(可選)功能依賴外部服務,可隨時在管理設定中停用。", + "onboarding_theme_description": "幫實例選色彩主題。之後也可以在設定中更改。", + "onboarding_welcome_description": "在啟用實例前先做一些基本的設定。", + "onboarding_welcome_user": "歡迎,{user}", + "online": "在線", + "only_favorites": "僅顯示己收藏", + "only_refreshes_modified_files": "只重新整理修改過的檔案", + "open_in_map_view": "開啟地圖檢視", + "open_in_openstreetmap": "用 OpenStreetMap 開啟", + "open_the_search_filters": "開啟搜尋篩選器", + "options": "選項", + "or": "或", + "organize_your_library": "整理您的圖庫", + "original": "原圖", + "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": "密碼重設成功", + "past_durations": { + "days": "過去 {days, plural, one {一天} other {# 天}}", + "hours": "過去 {hours, plural, one {一小時} other {# 小時}}", + "years": "過去 {years, plural, one {一年} other {# 年}}" + }, + "path": "路徑", + "pattern": "模式", + "pause": "暫停", + "pause_memories": "暫停回憶", + "paused": "已暫停", + "pending": "待處理", + "people": "人物", + "people_edits_count": "編輯了 {count, plural, one {# 位人士} other {# 位人士}}", + "people_feature_description": "以人物分組瀏覽照片和影片", + "people_sidebar_description": "在側邊欄顯示「人物」的連結", + "permanent_deletion_warning": "永久刪除警告", + "permanent_deletion_warning_setting_description": "在永久刪除檔案時顯示警告", + "permanently_delete": "永久刪除", + "permanently_delete_assets_count": "永久刪除 {count, plural, one {檔案} other {檔案}}", + "permanently_delete_assets_prompt": "確定要永久刪除 {count, plural, other {這 # 個檔案?}}這樣{count, plural, one {它} other {它們}}也會從自己所在的相簿中消失。", + "permanently_deleted_asset": "永久刪除的檔案", + "permanently_deleted_assets_count": "永久刪除的 {count, plural, one {# 個檔案} other {# 個檔案}}", + "person": "人物", + "person_hidden": "{name}{hidden, select, true {(隱藏)} other {}}", + "photo_shared_all_users": "看來您與所有使用者分享了照片,或沒有其他使用者可供分享。", + "photos": "照片", + "photos_and_videos": "照片及影片", + "photos_count": "{count, plural, other {{count, number} 張照片}}", + "photos_from_previous_years": "往年的照片", + "pick_a_location": "選擇位置", + "place": "地點", + "places": "地點", + "play": "播放", + "play_memories": "播放回憶", + "play_motion_photo": "播放動態照片", + "play_or_pause_video": "播放或暫停影片", + "point": "", + "port": "埠口", + "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 及開源軟體的支援", + "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": "伺服器產品金鑰是由管理者管理的", + "range": "", + "rating": "評星", + "rating_clear": "清除評等", + "rating_count": "{count, plural, other {# 星}}", + "rating_description": "在資訊面板中顯示 EXIF 評等", + "raw": "", + "reaction_options": "反應選項", + "read_changelog": "閱覽變更日誌", + "reassign": "重新指定", + "reassigned_assets_to_existing_person": "已將 {count, plural, other {# 個檔案}}重新指定給{name, select, null {現有的人} other {{name}}}", + "reassigned_assets_to_new_person": "已將 {count, plural, other {# 個檔案}}重新指定給一位新人物", + "reassing_hint": "將選定的檔案分配給己存在的人物", + "recent": "最近", + "recent_searches": "最近搜尋項目", + "refresh": "重新整理", + "refresh_encoded_videos": "重新整理已編碼的影片", + "refresh_faces": "重整面部資料", + "refresh_metadata": "重新整理元資料", + "refresh_thumbnails": "重新整理縮圖", + "refreshed": "重新整理完畢", + "refreshes_every_file": "重新讀取現有的所有檔案和新檔案", + "refreshing_encoded_video": "正在重新整理已編碼的影片", + "refreshing_faces": "重整面部資料中", + "refreshing_metadata": "正在重新整理元資料", + "regenerating_thumbnails": "重新產生縮圖中", + "remove": "移除", + "remove_assets_album_confirmation": "確定要從相簿中移除 {count, plural, other {# 個檔案}}嗎?", + "remove_assets_shared_link_confirmation": "確定要從此分享鏈結中移除{count, plural, other {# 個檔案}}嗎?", + "remove_assets_title": "移除檔案?", + "remove_custom_date_range": "移除自訂日期範圍", + "remove_deleted_assets": "移除離線檔案", + "remove_from_album": "從相簿中移除", + "remove_from_favorites": "從收藏中移除", + "remove_from_shared_link": "從分享鏈結中移除", + "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, other {# 個檔案}}的標記", + "rename": "改名", + "repair": "糾正", + "repair_no_results_message": "未被追蹤及遺失的檔案會顯示在這裏", + "replace_with_upload": "用上傳的檔案取代", + "repository": "儲存庫", + "require_password": "需要密碼", + "require_user_to_change_password_on_first_login": "要求使用者在首次登入時更改密碼", + "reset": "重設", + "reset_password": "重設密碼", + "reset_people_visibility": "重設人物可見性", + "reset_settings_to_default": "", + "reset_to_default": "重設回預設", + "resolve_duplicates": "解決重複項", + "resolved_all_duplicates": "已解決所有重複項目", + "restore": "還原", + "restore_all": "全部還原", + "restore_user": "還原使用者", + "restored_asset": "已還原檔案", + "resume": "繼續", + "retry_upload": "重新上傳", + "review_duplicates": "查核重複項目", + "role": "角色", + "role_editor": "編輯者", + "role_viewer": "檢視者", + "save": "儲存", + "saved_api_key": "已儲存的 API 密鑰", + "saved_profile": "已儲存個人資料", + "saved_settings": "已儲存設定", + "say_something": "说些什么", + "scan_all_libraries": "掃描所有圖庫", + "scan_all_library_files": "重新掃描所有圖庫文件", + "scan_library": "掃描", + "scan_new_library_files": "掃描新圖庫", + "scan_settings": "掃描設定", + "scanning_for_album": "掃描相簿中……", + "search": "搜尋", + "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_existing_person": "搜尋現有的人物", + "search_no_people": "沒有人找到", + "search_no_people_named": "沒有名爲「{name}」的人物", + "search_options": "搜尋選項", + "search_people": "搜尋人物", + "search_places": "搜尋地點", + "search_settings": "搜尋設定", + "search_state": "搜尋地區…", + "search_tags": "搜尋標記…", + "search_timezone": "搜尋時區…", + "search_type": "搜尋類型", + "search_your_photos": "搜尋照片", + "searching_locales": "搜尋區域…", + "second": "秒", + "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_photos": "選照片", + "select_trash_all": "全部刪除", + "selected": "已選擇", + "selected_count": "{count, plural, other {選了 # 項}}", + "send_message": "傳訊息", + "send_welcome_email": "傳送歡迎電子郵件", + "server": "", + "server_offline": "伺服器離線", + "server_online": "伺服器在線", + "server_stats": "伺服器統計", + "server_version": "目前版本", + "set": "設定", + "set_as_album_cover": "設爲相簿封面", + "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_photos_and_videos_count": "{assetCount, plural, other {已分享 # 張照片及影片。}}", + "shared_with_partner": "與 {partner} 共享", + "sharing": "共享", + "sharing_enter_password": "要查看此頁面請輸入密碼。", + "sharing_sidebar_description": "在側邊欄顯示共享連結", + "shift_to_permanent_delete": "按 ⇧ 永久刪除檔案", + "show_album_options": "顯示相簿選項", + "show_albums": "顯示相簿", + "show_all_people": "顯示所有人物", + "show_and_hide_people": "顯示與隱藏人物", + "show_file_location": "顯示文件位置", + "show_gallery": "顯示畫廊", + "show_hidden_people": "顯示隱藏的人物", + "show_in_timeline": "在時間軸中顯示", + "show_in_timeline_setting_description": "在您的時間軸中顯示這位使用者的照片和影片", + "show_keyboard_shortcuts": "顯示鍵盤快捷鍵", + "show_metadata": "顯示元資料", + "show_or_hide_info": "顯示或隱藏資訊", + "show_password": "顯示密碼", + "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_created": "建立日期", + "sort_items": "項目數量", + "sort_modified": "日期已修改", + "sort_oldest": "最舊的照片", + "sort_recent": "最新的照片", + "sort_title": "標題", + "source": "來源", + "stack": "堆叠", + "stack_duplicates": "堆疊重複項目", + "stack_select_one_photo": "爲堆疊選一張主要照片", + "stack_selected_photos": "堆疊所選的照片", + "stacked_assets_count": "已堆疊 {count, plural, one {# 個檔案} other {# 個檔案}}", + "stacktrace": "堆疊追蹤", + "start": "開始", + "start_date": "開始日期", + "state": "地區", + "status": "狀態", + "stop_motion_photo": "停止動態照片", + "stop_photo_sharing": "要停止分享您的照片嗎?", + "stop_photo_sharing_description": "{partner} 將無法再訪問你的照片。", + "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": "同步", + "tag": "標記", + "tag_assets": "標記檔案", + "tag_created": "已建立標記:{tag}", + "tag_feature_description": "以邏輯標記要旨分組瀏覽照片和影片", + "tag_not_found_question": "找不到標記?建立新標記吧。", + "tag_updated": "已更新標記:{tag}", + "tagged_assets": "已標記 {count, plural, other {# 個檔案}}", + "tags": "標記", + "template": "模板", + "theme": "主題", + "theme_selection": "主題選項", + "theme_selection_description": "依瀏覽器系統偏好自動設定深、淺色主題", + "they_will_be_merged_together": "它們將會被合併在一起", + "third_party_resources": "第三方資源", + "time_based_memories": "依時間回憶", + "timezone": "時區", + "to_archive": "封存", + "to_change_password": "更改密碼", + "to_favorite": "收藏", + "to_login": "登入", + "to_parent": "到上一級", + "to_root": "到根", + "to_trash": "垃圾桶", + "toggle_settings": "切換設定", + "toggle_theme": "切換深色主題", + "toggle_visibility": "", + "total_usage": "總用量", + "trash": "垃圾桶", + "trash_all": "全部丟掉", + "trash_count": "丟掉 {count, number} 個檔案", + "trash_delete_asset": "將檔案丟進垃圾桶 / 刪除", + "trash_no_results_message": "垃圾桶中的照片和影片將顯示在這裡。", + "trashed_items_will_be_permanently_deleted_after": "垃圾桶中的項目會在 {days, plural, other {# 天}}後永久刪除。", + "type": "類型", + "unarchive": "取消封存", + "unarchived": "", + "unarchived_count": "{count, plural, other {已取消封存 # 個項目}}", + "unfavorite": "取消收藏", + "unhide_person": "取消隱藏人物", + "unknown": "未知", + "unknown_album": "", + "unknown_year": "不知年份", + "unlimited": "不限制", + "unlink_motion_video": "取消鏈結動態影片", + "unlink_oauth": "取消連接 OAuth", + "unlinked_oauth_account": "已解除連接 OAuth 帳號", + "unnamed_album": "未命名相簿", + "unnamed_album_delete_confirmation": "確定要刪除這本相簿嗎?", + "unnamed_share": "未命名分享", + "unsaved_change": "更改未儲存", + "unselect_all": "取消全選", + "unselect_all_duplicates": "取消選取所有的重複項目", + "unstack": "取消堆叠", + "unstacked_assets_count": "已解除堆疊 {count, plural, other {# 個檔案}}", + "untracked_files": "未被追蹤的檔案", + "untracked_files_decription": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", + "up_next": "下一個", + "updated_password": "已更新密碼", + "upload": "上傳", + "upload_concurrency": "上傳並行", + "upload_errors": "上傳完成,但有 {count, plural, other {# 處出錯}},要查看新上傳的檔案請重新整理頁面。", + "upload_progress": "剩餘 {remaining, number} - 已處理 {processed, number}/{total, number}", + "upload_skipped_duplicates": "已略過 {count, plural, other {# 個重複的檔案}}", + "upload_status_duplicates": "重複項目", + "upload_status_errors": "錯誤", + "upload_status_uploaded": "已上傳", + "upload_success": "上傳成功,要查看新上傳的檔案請重新整理頁面。", + "url": "網址", + "usage": "用量", + "use_custom_date_range": "改用自訂日期範圍", + "user": "使用者", + "user_id": "使用者 ID", + "user_liked": "{user} 喜歡了 {type, select, photo {這張照片} video {這段影片} asset {這個檔案} other {它}}", + "user_purchase_settings": "購置", + "user_purchase_settings_description": "管理你的購買", + "user_role_set": "設 {user} 爲{role}", + "user_usage_detail": "使用者用量詳情", + "username": "使用者名稱", + "users": "使用者", + "utilities": "工具", + "validate": "驗證", + "variables": "變數", + "version": "版本", + "version_announcement_closing": "敬祝順心,Alex", + "version_announcement_message": "嗨~本應用程式可以更新了,爲防止配置出錯,請花點時間閱讀發行說明,並確保 docker-compose.yml.env 設置是最新的,特別是使用 WatchTower 等自動更新工具時。", + "version_history": "版本紀錄", + "version_history_item": "{date} 安裝了 {version}", + "video": "影片", + "video_hover_setting": "游標停留時播放影片縮圖", + "video_hover_setting_description": "當滑鼠停在項目上時播放影片縮圖。即使停用,將滑鼠停在播放圖示上也可以播放。", + "videos": "影片", + "videos_count": "{count, plural, other {# 部影片}}", + "view": "查看", + "view_album": "查看相簿", + "view_all": "瀏覽全部", + "view_all_users": "查看所有使用者", + "view_in_timeline": "在時間軸中查看", + "view_links": "檢視鏈結", + "view_next_asset": "查看下一項", + "view_previous_asset": "查看上一項", + "view_stack": "查看堆疊", + "viewer": "", + "visibility_changed": "已更改 {count, plural, other {# 位人物}}的可見性", + "waiting": "待處理", + "warning": "警告", + "week": "周", + "welcome": "歡迎", + "welcome_to_immich": "歡迎使用 Immich", + "year": "年", + "years_ago": "{years, plural, other {# 年}}前", + "yes": "是", + "you_dont_have_any_shared_links": "您沒有分享鏈結", + "zoom_image": "縮放圖片" +} diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json similarity index 80% rename from web/src/lib/i18n/zh_SIMPLIFIED.json rename to i18n/zh_SIMPLIFIED.json index e09cb1ecdf..c2e1a87350 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -7,7 +7,7 @@ "actions": "操作", "active": "正在处理", "activity": "活动", - "activity_changed": "活动已{enabled, select, true {启用} other {禁用}}", + "activity_changed": "活动已{enabled, select, true {启用} other {停用}}", "add": "添加", "add_a_description": "添加描述", "add_a_location": "添加位置", @@ -15,76 +15,88 @@ "add_a_title": "添加标题", "add_exclusion_pattern": "添加排除规则", "add_import_path": "添加导入路径", - "add_location": "添加位置", + "add_location": "添加地点", "add_more_users": "添加更多用户", "add_partner": "添加同伴", "add_path": "添加路径", "add_photos": "添加照片", - "add_to": "添加至...", - "add_to_album": "添加至相册", - "add_to_shared_album": "添加至共享相册", - "added_to_archive": "添加至归档", - "added_to_favorites": "添加至收藏", - "added_to_favorites_count": "添加{count, number}项至收藏", + "add_to": "添加到...", + "add_to_album": "添加到相册", + "add_to_shared_album": "添加到共享相册", + "added_to_archive": "添加到归档", + "added_to_favorites": "添加到收藏", + "added_to_favorites_count": "添加{count, number}项到收藏", "admin": { - "add_exclusion_pattern_description": "添加排除规则。支持使用 *,** 和 ? 进行通配。要忽略名为 “Raw” 的任何目录中的所有文件,请使用 “**/Raw/**”。要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif\"。要忽略绝对路径,请使用 \"/path/to/ignore/**”。", + "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略任何名为 “Raw” 的文件夹中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", + "asset_offline_description": "此外部库项目已无法从磁盘中找到,并已移至回收站。如果文件已在库中移动,请检查时间线中是否有新的对应项目。要恢复此项目,请确保 Immich 可以访问以下文件路径并执行扫描库任务。", "authentication_settings": "认证设置", "authentication_settings_description": "管理密码、OAuth 和其它认证设置", - "authentication_settings_disable_all": "确定要禁用所有的登录方式?此操作将完全禁用登录。", + "authentication_settings_disable_all": "确定要禁用所有的登录方式?该操作将完全禁止登录。", "authentication_settings_reenable": "如需再次启用,使用 服务器指令。", "background_task_job": "后台任务", "check_all": "检查全部", - "cleared_jobs": "已清理作业:{job}", + "cleared_jobs": "已清理任务:{job}", "config_set_by_file": "当前配置已通过配置文件设置", - "confirm_delete_library": "是否确定要删除图库{library} ?", - "confirm_delete_library_assets": "是否确定要删除该图库?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。文件仍将保留在磁盘中。", + "confirm_delete_library": "确定要删除图库“{library}”吗?", + "confirm_delete_library_assets": "确定要删除该图库吗?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。但文件仍将保留在磁盘中。", "confirm_email_below": "输入“{email}”来确认", - "confirm_reprocess_all_faces": "是否确定对全部照片重新进行面部识别?这将同时清除所有已命名人物。", - "confirm_user_password_reset": "是否确定重置用户{user}的密码?", + "confirm_reprocess_all_faces": "确定要对全部照片重新进行面部识别吗?这将同时清除所有已命名人物。", + "confirm_user_password_reset": "确定要重置用户{user}的密码吗?", + "create_job": "创建任务", "crontab_guru": "Crontab Guru", "disable_login": "禁用登录", "disabled": "已禁用", - "duplicate_detection_job_description": "对照片进行机器学习处理来检测相似项目。依赖于智能搜索", + "duplicate_detection_job_description": "对照片进行机器学习处理来检测相似项目,依赖于智能搜索", "exclusion_pattern_description": "排除规则允许在扫描图库时忽略文件和文件夹。如果有包含不想导入的文件的文件夹,例如RAW文件,排除规则将非常有用。", "external_library_created_at": "外部图库(创建于{date})", "external_library_management": "外部图库管理", "face_detection": "人脸检测", - "face_detection_description": "使用机器学习检测项目中的人脸(视频只检测其缩略图中的人脸)。选择“全部”项将会(重新)处理所有项目。选择“缺失”项将尚未处理的项目置于队列中。人脸检测完成后,检测到的人脸将排队进行面部识别,将它们分组到现有的或新的人物中。", - "facial_recognition_job_description": "将检测到的人脸按照人物分组。这一步将在人脸检测完成后执行。选择“全部”项将会(重新)分组所有面孔。选择“缺失”项将尚未分配的人脸置于队列中。", - "failed_job_command": "{command}命令执行失败的作业:{job}", - "force_delete_user_warning": "警告:这将立即移除用户以及所有项目。该操作无法撤回且文件无法恢复。", + "face_detection_description": "使用机器学习检测项目中的人脸(视频只检测其缩略图中的人脸)。选择“刷新”项将会(重新)处理所有项目。选择“重置”还会清除所有当前面部数据。选择“缺失”项将尚未处理的项目进行排队处理。人脸检测完成后,检测到的人脸将排队进行面部识别,将它们分组到现有的或新的人物中。", + "facial_recognition_job_description": "将检测到的人脸按照人物分组。这一步将在人脸检测完成后执行。选择“重置”项将会(重新)分组所有面孔。选择“缺失”项将尚未分配的人脸置于队列中。", + "failed_job_command": "{command}命令执行失败的任务:{job}", + "force_delete_user_warning": "警告:这将立即移除用户以及所有项目。该操作无法撤销且文件无法恢复。", "forcing_refresh_library_files": "强制刷新所有图库文件", + "image_format": "格式", "image_format_description": "WebP 文件比 JPEG 文件小,但编码速度较慢。", "image_prefer_embedded_preview": "嵌入式预览", "image_prefer_embedded_preview_setting_description": "在可用时,使用 RAW 照片的嵌入式预览作为图像处理的输入。这可能为某些图像产生更准确的颜色,但预览的质量取决于相机,图像可能具有更多的压缩失真。", "image_prefer_wide_gamut": "广色域", "image_prefer_wide_gamut_setting_description": "对缩略图使用 Display P3。这可以更好地保留宽色域图像的鲜艳度,但在旧设备和旧版浏览器上图像可能会显得不同。sRGB 图像保持为 sRGB 以避免颜色偏移。", + "image_preview_description": "去除元数据的中尺寸图像,用于单一项目查看和机器学习", "image_preview_format": "预览格式", + "image_preview_quality_description": "预览质量从 1 到 100。越高越好,但会产生更大的文件,并且会降低系统的响应能力。设置较低的值可能会影响机器学习的质量。", "image_preview_resolution": "预览分辨率", "image_preview_resolution_description": "在查看单张照片和进行机器学习时使用。更高的分辨率可以保留更多细节,但编码时间更长,文件体积更大,且可能降低应用程序的响应速度。", + "image_preview_title": "预览设置", "image_quality": "质量", "image_quality_description": "图像质量从1到100。数字越高,质量越好,但生成的文件也越大,此选项会同时影响预览和缩略图。", + "image_resolution": "分辨率", + "image_resolution_description": "更高的分辨率可以保留更多细节,但编码时间更长,文件体积更大,而且会降低系统的响应速度。", "image_settings": "图片设置", "image_settings_description": "管理生成图像的质量和分辨率", + "image_thumbnail_description": "去除元数据的小缩略图,用于浏览主时间线等照片组", "image_thumbnail_format": "缩略图格式", + "image_thumbnail_quality_description": "缩略图质量从 1 到 100。越高越好,但会产生更大的文件,并且会降低系统的响应能力。", "image_thumbnail_resolution": "缩略图分辨率", "image_thumbnail_resolution_description": "用于查看照片组(主时间轴、相册视图等)。更高的分辨率可以保留更多的细节,但编码时间更长,文件体积更大,并会降低应用程序的响应速度。", + "image_thumbnail_title": "缩略图设置", "job_concurrency": "{job}并发", + "job_created": "任务已创建", "job_not_concurrency_safe": "此任务并发并不安全。", "job_settings": "任务设置", "job_settings_description": "管理任务并发", "job_status": "任务状态", - "jobs_delayed": "{jobCount, plural, other {#项作业已推迟}}", + "jobs_delayed": "{jobCount, plural, other {#项任务已推迟}}", "jobs_failed": "{jobCount, plural, other {#项失败}}", "library_created": "已创建图库:{library}", "library_cron_expression": "Cron 表达式", "library_cron_expression_description": "使用 cron 格式设置扫描间隔。关于 cron 格式请参阅Crontab Guru", "library_cron_expression_presets": "Cron 表达式预设", "library_deleted": "图库已删除", - "library_import_path_description": "指定一个要导入的文件夹。该文件夹及其子文件夹将被扫描以查找图片和视频。", + "library_import_path_description": "指定一个要导入的文件夹。将扫描此文件夹(包括子文件夹)中的图像和视频。", "library_scanning": "定期扫描", - "library_scanning_description": "配置定期图库扫描", - "library_scanning_enable_description": "启用定期图库扫描", + "library_scanning_description": "配置定期扫描图库", + "library_scanning_enable_description": "启用定期扫描图库", "library_settings": "外部图库", "library_settings_description": "管理外部图库设置", "library_tasks_description": "执行图库任务", @@ -95,18 +107,18 @@ "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": "如果禁用此功能,完全相同的项目仍将被去重。", "machine_learning_duplicate_detection_setting_description": "使用CLIP向量匹配(关键词相似度)来查找可能的重复项", "machine_learning_enabled": "启用机器学习", - "machine_learning_enabled_description": "如果禁用,不论以下设置如何,所有机器学习功能将被禁用。", + "machine_learning_enabled_description": "如果禁用,无论以下如何设置,所有机器学习功能将被禁用。", "machine_learning_facial_recognition": "人脸识别", - "machine_learning_facial_recognition_description": "检测、识别并分组图像中的人脸", + "machine_learning_facial_recognition_description": "检测、识别并将图像中的人脸分组", "machine_learning_facial_recognition_model": "人脸识别模型", "machine_learning_facial_recognition_model_description": "机器学习模型按规模大小降序排列。更大的模型速度更慢,占用的内存更多,但效果更好。请注意,在更换模型后,必须对所有图像重新运行人脸检测。", - "machine_learning_facial_recognition_setting": "启用面部识别", + "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。较高的值将检测出更多的重复图片,但可能导致误报。", @@ -115,11 +127,11 @@ "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_description": "创建新人物所需最少识别的数量。提高这个值可以使面部识别更精确,但也增加了面孔未能被分配到对应人物的可能性。", "machine_learning_settings": "机器学习设置", "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": "机器学习服务器的URL", @@ -129,26 +141,31 @@ "map_enable_description": "启用地图功能", "map_gps_settings": "地图与GPS设置", "map_gps_settings_description": "管理地图与GPS(反向地理编码)设置", + "map_implications": "地图功能依赖于外部瓦片服务(tiles.immich.cloud)", "map_light_style": "浅色模式", "map_manage_reverse_geocoding_settings": "管理反向地理编码设置", "map_reverse_geocoding": "反向地理编码", "map_reverse_geocoding_enable_description": "启用反向地理编码", "map_reverse_geocoding_settings": "反向地理编码设置", - "map_settings": "地图设置", + "map_settings": "地图", "map_settings_description": "管理地图设置", "map_style_description": "地图主题 style.json 的 URL", "metadata_extraction_job": "提取元数据", - "metadata_extraction_job_description": "从每个项目中提取元数据信息,如GPS和分辨率", + "metadata_extraction_job_description": "从每个项目中提取元数据信息,如GPS、人脸和分辨率", + "metadata_faces_import_setting": "启用人脸导入", + "metadata_faces_import_setting_description": "从图片的EXIF和辅助元数据中导入人脸", + "metadata_settings": "元数据设置", + "metadata_settings_description": "管理元数据设置", "migration_job": "迁移", "migration_job_description": "将项目和人脸识别的缩略图迁移到最新的文件夹结构", "no_paths_added": "无已添加路径", "no_pattern_added": "无已添加规则", - "note_apply_storage_label_previous_assets": "提示:要将存储标签应用于之前上传的项目,运行以下命令", - "note_cannot_be_changed_later": "注意:此项一旦设定,后续无法更改!", - "note_unlimited_quota": "提示:输入0表示无限制配额", + "note_apply_storage_label_previous_assets": "提示:要将存储标签应用于之前上传的项目,需要运行", + "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", + "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich Photo Server ”", - "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", + "notification_email_from_address_description": "发件人邮箱地址,例如:“张三 <12345@qq.com>”", + "notification_email_host_description": "服务器地址(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", "notification_email_password_description": "与邮件服务器进行身份验证时使用的密码", @@ -156,8 +173,8 @@ "notification_email_sent_test_email_button": "发送测试邮件并保存", "notification_email_setting_description": "发送邮件通知设置", "notification_email_test_email": "发送测试邮件", - "notification_email_test_email_failed": "发送测试邮件失败,检查你的输入", - "notification_email_test_email_sent": "已向{email}发送了一封测试邮件。请检查您的收件箱。", + "notification_email_test_email_failed": "发送测试邮件失败,请检查你输入的信息", + "notification_email_test_email_sent": "已向{email}发送了一封测试邮件,请注意查收。", "notification_email_username_description": "与邮件服务器进行身份验证时使用的用户名", "notification_enable_email_notifications": "启用邮件通知", "notification_settings": "通知设置", @@ -166,46 +183,49 @@ "oauth_auto_launch_description": "在登录页面自动启动OAuth登录", "oauth_auto_register": "自动注册", "oauth_auto_register_description": "使用OAuth登录后自动注册新用户", - "oauth_button_text": "按钮文本", - "oauth_client_id": "Client ID", - "oauth_client_secret": "客户端密匙", + "oauth_button_text": "按钮名称", + "oauth_client_id": "客户端ID", + "oauth_client_secret": "客户端密钥", "oauth_enable_description": "使用OAuth登录", - "oauth_issuer_url": "发行方的网址", + "oauth_issuer_url": "发行方网址", "oauth_mobile_redirect_uri": "移动端重定向 URI", "oauth_mobile_redirect_uri_override": "移动端重定向 URI 覆盖", - "oauth_mobile_redirect_uri_override_description": "当 \"app.immich:/\"无效时,启用URL重定向。", + "oauth_mobile_redirect_uri_override_description": "当 OAuth 提供商不允许使用移动 URI 时启用,如“'{callback}'”", "oauth_profile_signing_algorithm": "配置文件签名算法", "oauth_profile_signing_algorithm_description": "用于签署用户配置文件的算法。", "oauth_scope": "范围", "oauth_settings": "OAuth", "oauth_settings_description": "管理OAuth登录设置", - "oauth_settings_more_details": "关于本功能的更多详细信息,请见文档。", + "oauth_settings_more_details": "关于本功能的更多详细信息,请查看相关文档。", "oauth_signing_algorithm": "签名算法", "oauth_storage_label_claim": "存储标签声明", "oauth_storage_label_claim_description": "自动将用户的存储标签设置为此项的值。", "oauth_storage_quota_claim": "存储配额声明", "oauth_storage_quota_claim_description": "自动将用户的存储配额设置为此项的值。", - "oauth_storage_quota_default": "默认存储配额 (GiB)", - "oauth_storage_quota_default_description": "没有提供声明时,要使用的GiB配额(输入0表示无限制配额)。", + "oauth_storage_quota_default": "默认存储配额(GB)", + "oauth_storage_quota_default_description": "没有提供声明时,要使用的配额大小(GB)(输入0表示无限制)。", "offline_paths": "离线文件", "offline_paths_description": "这可能是由于手动删除了不属于外部图库的文件。", "password_enable_description": "使用邮箱和密码登录", "password_settings": "密码登录", "password_settings_description": "管理密码登录设置", "paths_validated_successfully": "所有路径验证成功", - "quota_size_gib": "配额大小(GiB)", + "person_cleanup_job": "清理人物", + "quota_size_gib": "配额大小(GB)", "refreshing_all_libraries": "刷新所有图库", "registration": "注册管理员", - "registration_description": "因为您是本系统的第一个用户,您被指派为管理员,负责相关管理工作,并且由您来创建新的用户。", - "removing_offline_files": "移除离线文件", + "registration_description": "由于您是系统上的第一个用户,您将被指定为管理员并负责管理任务,由您来创建新的用户。", + "removing_deleted_files": "移除离线文件", "repair_all": "修复所有", "repair_matched_items": "匹配到 {count, plural, one {#个项目} other {#个项目}}", "repaired_items": "已修复{count, plural, one {#个项目} other {#个项目}}", - "require_password_change_on_login": "要求用户第一次登录后修改密码", + "require_password_change_on_login": "要求用户首次登录时更改密码", "reset_settings_to_default": "恢复默认设置", "reset_settings_to_recent_saved": "恢复到最近保存的设置", - "scanning_library_for_changed_files": "扫描图库文件变更", - "scanning_library_for_new_files": "扫描图库文件增添", + "scanning_library": "扫描图库", + "scanning_library_for_changed_files": "扫描图库变更的文件", + "scanning_library_for_new_files": "扫描图库新增的文件", + "search_jobs": "搜索任务...", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -216,16 +236,16 @@ "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_date_time_sample": "采样时间{date}", "storage_template_enable_description": "启用存储模板", "storage_template_hash_verification_enabled": "哈希校验已启用", "storage_template_hash_verification_enabled_description": "启用哈希校验,如果您不知道此项的作用请不要禁用此功能", "storage_template_migration": "存储模板转换", - "storage_template_migration_description": "应用当前模板{template}到之前上传的项目", + "storage_template_migration_description": "应用当前的{template}到之前上传的项目", "storage_template_migration_info": "模板修改将只作用于新的项目。如也需应用此模板到之前上传的项目,请运行{job}。", - "storage_template_migration_job": "存储模板迁移任务", + "storage_template_migration_job": "存储模板转换任务", "storage_template_more_details": "关于本功能的更多细节,请参见存储模板及其实现方式", "storage_template_onboarding_description": "启用后,本功能将根据用户定义的模板自动整理文件。出于稳定性考虑,本功能默认是禁用的。更多详细信息请参见 文档。", "storage_template_path_length": "路径的字符长度及限制:{length, number}/{limit, number}", @@ -233,10 +253,11 @@ "storage_template_settings_description": "管理上传项目文件夹结构和文件名", "storage_template_user_label": "{label}是用户的存储标签", "system_settings": "系统设置", + "tag_cleanup_job": "清理标签", "theme_custom_css_settings": "自定义CSS", "theme_custom_css_settings_description": "可以通过CSS自定义Immich外观。", "theme_settings": "主题设置", - "theme_settings_description": "管理Immich web界面定制", + "theme_settings_description": "管理Immich web界面的定制", "these_files_matched_by_checksum": "这些文件与校验匹配", "thumbnail_generation_job": "生成缩略图", "thumbnail_generation_job_description": "为每个项目生成不同尺寸的缩略图,并为每个人物生成缩略图", @@ -244,7 +265,7 @@ "transcoding_acceleration_api": "加速器API", "transcoding_acceleration_api_description": "这个API将与您的设备交互,以加速转码过程。此设置为“尽力而为”——如果转码失败,将回到软件转码。VP9是否工作取决于您的硬件配置。", "transcoding_acceleration_nvenc": "NVENC(需要 NVIDIA GPU)", - "transcoding_acceleration_qsv": "快速同步(需要 7代及以上的 Intel CPU)", + "transcoding_acceleration_qsv": "快速同步(需要Intel 7代及以上的 CPU)", "transcoding_acceleration_rkmpp": "RKMPP(仅适用于 Rockchip SOCs)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "支持的音频编解码器", @@ -260,44 +281,44 @@ "transcoding_codecs_learn_more": "要了解此处使用的术语详情,请参见FFmpeg文档H.264编解码HEVC编解码VP9编解码。", "transcoding_constant_quality_mode": "恒定质量模式", "transcoding_constant_quality_mode_description": "ICQ比CQP更好,但一些硬件加速设备不支持这种模式。当使用基于质量的编码时,此选项将为首选指定的模式。使用NVENC时忽略此选项,因为NVENC不支持ICQ。", - "transcoding_constant_rate_factor": "恒定码率", + "transcoding_constant_rate_factor": "恒定码率系数(-crf)", "transcoding_constant_rate_factor_description": "视频质量水平。H.264的典型值为23,HEVC为28,VP9为31,AV1为35。越低画面质量越好,但产生的文件越大。", "transcoding_disabled_description": "不要对任何视频进行转码,在某些客户端上可能会无法播放", "transcoding_hardware_acceleration": "硬件加速", "transcoding_hardware_acceleration_description": "(实验性功能)速度更快,但在相同码率下质量会降低", "transcoding_hardware_decoding": "硬件解码", - "transcoding_hardware_decoding_setting_description": "仅适用于NVENC、QSV和RKMPP。启用端到端加速,而不仅仅是加速编码。可能并不适用于所有视频。", + "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帧,-1表示将自动设置此参数。", "transcoding_max_bitrate": "最高码率", "transcoding_max_bitrate_description": "设置最大比特率可以使文件大小更可控,而对质量的影响很小。在720p时,VP9或HEVC的典型值为2600k,而H.264的典型值则为4500k。如果此项设置为0,则不限制最大比特率。", "transcoding_max_keyframe_interval": "最大关键帧间隔", - "transcoding_max_keyframe_interval_description": "设置关键帧之间的最大帧距离。较低的值会降低压缩效率,但可以提高搜索速度,并且可能在快速运动的场景中提高画质。值设为0将自动设置此参数。", + "transcoding_max_keyframe_interval_description": "设置关键帧之间的最大帧距离。较低的值会降低压缩效率,但可以提高搜索速度,并且可能在快速运动的场景中提高画质。0表示将自动设置此参数。", "transcoding_optimal_description": "视频超过目标分辨率或格式不支持", "transcoding_preferred_hardware_device": "首选硬件设备", "transcoding_preferred_hardware_device_description": "仅适用于VAAPI和QSV。设置用于硬件转码的dri节点。", - "transcoding_preset_preset": "预设(-预设)", + "transcoding_preset_preset": "预设(-preset)", "transcoding_preset_preset_description": "压缩速度。较慢的预设会产生更小的文件,并在目标特定比特率时提高质量。VP9请忽略faster以上的速度。", "transcoding_reference_frames": "参考帧", - "transcoding_reference_frames_description": "在压缩给定帧时参考的帧数。较高的值可以提高压缩效率,但会减慢编码速度。此项设置为0时将自动设置此参数。", + "transcoding_reference_frames_description": "在压缩给定帧时参考的帧数。较高的值可以提高压缩效率,但会减慢编码速度。0表示将自动设置此参数。", "transcoding_required_description": "仅限不兼容格式的视频", "transcoding_settings": "视频转码设置", "transcoding_settings_description": "管理视频文件的分辨率和编码信息", "transcoding_target_resolution": "目标分辨率", "transcoding_target_resolution_description": "更高的分辨率可以保留更多细节,但编码时间更长,文件体积更大,且可能降低应用程序的响应速度。", - "transcoding_temporal_aq": "Temporal AQ", + "transcoding_temporal_aq": "时间自适应量化", "transcoding_temporal_aq_description": "仅适用于NVENC。提高高细节、低动态场景的质量。可能与旧设备不兼容。", "transcoding_threads": "线程数", - "transcoding_threads_description": "设定值越高,编码速度越快,留给其它任务(Docker外宿主机的任务等)的计算能力越少。此值不应大于CPU核心的数量。如果值设置为0,则最大限度地提高利用率。", + "transcoding_threads_description": "设定值越高,编码速度越快,留给其它任务(Docker外宿主机的任务等)的计算能力越少。此值不应大于CPU核心的数量。0表示最大限度地提高利用率。", "transcoding_tone_mapping": "色调映射", "transcoding_tone_mapping_description": "在将HDR视频转换为SDR时,尝试保持其外观。每种算法在颜色、细节和亮度方面做出了不同的权衡。Hable算法保留细节,Mobius算法保留颜色,而Reinhard算法保留亮度。", "transcoding_tone_mapping_npl": "NPL色调映射", - "transcoding_tone_mapping_npl_description": "对于这种亮度的显示器,颜色将被调整到显示正常。与直觉相反,较低的值会增加视频的亮度,反之亦然,因为它会补偿显示器的亮度。填0将自动设置此值。", + "transcoding_tone_mapping_npl_description": "对于这种亮度的显示器,颜色将被调整到显示正常。与直觉相反,较低的值会增加视频的亮度,反之亦然,因为它会补偿显示器的亮度。0表示将自动设置此值。", "transcoding_transcode_policy": "转码策略", "transcoding_transcode_policy_description": "视频转码策略。HDR视频将始终进行转码(除非禁用了转码功能)。", "transcoding_two_pass_encoding": "二次编码", - "transcoding_two_pass_encoding_setting_description": "分两次进行转码,以生成更好的编码视频。当启用最大比特率(与H.264和HEVC一起工作所需)时,此模式使用基于最大比特率的比特率范围,并忽略CRF。对于VP9,如果禁用了最大比特率,则可以使用CRF。\n注:CRF,全称为constant ratefactor,是指保证“一定质量”,智能分配码率,包括同一帧内分配码率、帧间分配码率。", + "transcoding_two_pass_encoding_setting_description": "分两次进行转码,以生成更好的编码视频。当启用最大比特率(与H.264和HEVC一起工作所需)时,此模式使用基于最大比特率的比特率范围,并忽略CRF。对于VP9,如果禁用了最大比特率,则可以使用CRF(注:CRF,全称为constant rate factor,是指保证“一定质量”,智能分配码率,包括同一帧内分配码率、帧间分配码率)。", "transcoding_video_codec": "视频编解码器", "transcoding_video_codec_description": "VP9具有很高的效率和网络兼容性,但转码需要更长的时间。HEVC的性能相似,但网络兼容性较低。H.264转码快速且具有广泛的兼容性,但产生的文件要大得多。AV1是最高效的编解码器,但在较旧的设备上缺乏支持。", "trash_enabled_description": "启用回收站", @@ -307,20 +328,22 @@ "trash_settings_description": "管理回收站设置", "untracked_files": "未被追踪的文件", "untracked_files_description": "这些文件未被系统追踪。 这可能是移动失败、上传中断或因bug而落下", + "user_cleanup_job": "清理用户", "user_delete_delay": "{user}的账户及项目将在{delay, plural, one {#天} other {#天}}后自动永久删除。", "user_delete_delay_settings": "延期删除", - "user_delete_delay_settings_description": "移除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", + "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_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_enabled_description": "启用版本检测", + "version_check_implications": "版本检查功能依赖于与 github.com 的定期通信", "version_check_settings": "版本检查", "version_check_settings_description": "启用或禁用新版本通知", "video_conversion_job": "视频转码", @@ -336,14 +359,15 @@ "album_added": "相册已添加", "album_added_notification_setting_description": "当您被添加到共享相册时,接收电子邮件通知", "album_cover_updated": "相册封面已更新", - "album_delete_confirmation": "是否确定要删除相册{album}?\n如果这是共享相册,其他用户将无法再访问它。", + "album_delete_confirmation": "确定要删除相册“{album}”吗?", + "album_delete_confirmation_description": "如果该相册是共享的,其他用户将无法再访问它。", "album_info_updated": "相册信息已更新", "album_leave": "退出相册?", - "album_leave_confirmation": "确定要退出相册{album}?", + "album_leave_confirmation": "确定要退出相册{album}吗?", "album_name": "相册名称", "album_options": "相册设置", "album_remove_user": "移除用户?", - "album_remove_user_confirmation": "你确定要移除{user}?", + "album_remove_user_confirmation": "你确定要移除{user}吗?", "album_share_no_users": "看起来您已与所有用户共享了此相册,或者您根本没有任何用户可共享。", "album_updated": "相册已更新", "album_updated_setting_description": "当共享相册有新项目时接收邮件通知", @@ -358,31 +382,33 @@ "all_videos": "所有视频", "allow_dark_mode": "允许深色模式", "allow_edits": "允许编辑", - "allow_public_user_to_download": "开放下载给所有人", + "allow_public_user_to_download": "允许所有用户下载", "allow_public_user_to_upload": "允许所有用户上传", - "api_key": "API Key", + "anti_clockwise": "逆时针", + "api_key": "API 密钥", "api_key_description": "该应用密钥只会展示一次。请确保在关闭窗口前复制下来。", "api_key_empty": "API Key的名称不可以为空", - "api_keys": "API Keys", + "api_keys": "API 密钥", "app_settings": "应用设置", "appears_in": "出现于", "archive": "归档", "archive_or_unarchive_photo": "归档或取消归档照片", "archive_size": "归档大小", - "archive_size_description": "配置下载归档大小(以GiB为单位)", + "archive_size_description": "配置下载归档大小(GB)", "archived": "已归档", - "archived_count": "{count, plural, other {已存档 # 项}}", - "are_these_the_same_person": "是否是同一个人?", + "archived_count": "{count, plural, other {已归档 # 项}}", + "are_these_the_same_person": "是同一个人吗?", "are_you_sure_to_do_this": "确定要这样做吗?", "asset_added_to_album": "已添加至相册", "asset_adding_to_album": "正在添加至相册...", "asset_description_updated": "项目描述已更新", "asset_filename_is_offline": "项目{filename}已离线", - "asset_has_unassigned_faces": "项目中有未认领的人脸", + "asset_has_unassigned_faces": "项目中有未分配的人脸", "asset_hashing": "哈希校验中...", "asset_offline": "项目离线", - "asset_offline_description": "项目已离线。Immich无法访问该文件。请确保项目可读并重新扫描项目库。", + "asset_offline_description": "磁盘上已找不到该外部项目。请联系您的 Immich 管理员寻求帮助。", "asset_skipped": "已跳过", + "asset_skipped_in_trash": "已回收", "asset_uploaded": "已上传", "asset_uploading": "上传中...", "assets": "项目", @@ -393,21 +419,22 @@ "assets_moved_to_trash": "将{count, plural, one {# 个项目} other {# 个项目}}移动到回收站", "assets_moved_to_trash_count": "已移动{count, plural, one {#个项目} other {#个项目}}到回收站", "assets_permanently_deleted_count": "已永久删除{count, plural, one {#个项目} other {#个项目}}", - "assets_removed_count": "移除{count, plural, one {#个项目} other {#个项目}}", - "assets_restore_confirmation": "确定要恢复回收站中的所有项目吗?该操作无法撤消!", + "assets_removed_count": "已移除{count, plural, one {#个项目} other {#个项目}}", + "assets_restore_confirmation": "确定要恢复回收站中的所有项目吗?该操作无法撤消!请注意,脱机项目无法通过这种方式恢复。", "assets_restored_count": "已恢复{count, plural, one {#个项目} other {#个项目}}", "assets_trashed_count": "{count, plural, one {#个项目} other {#个项目}}已放入回收站", "assets_were_part_of_album_count": "{count, plural, one {项目} other {项目}}已经在相册中", "authorized_devices": "已授权设备", - "back": "后退", - "back_close_deselect": "后退、关闭或反选", + "back": "返回", + "back_close_deselect": "返回、关闭或反选", "backward": "后退", "birthdate_saved": "出生日期保存成功", - "birthdate_set_description": "出生日期用于计算照片中人物当时的年龄。", + "birthdate_set_description": "出生日期用于计算照片中该人物在拍照时的年龄。", "blurred_background": "背景模糊", + "bugs_and_feature_requests": "Bug和特性要求", "build": "构建版本", "build_image": "镜像版本", - "bulk_delete_duplicates_confirmation": "您确定要批量删除{count, plural, one {#个重复项目} other {#个重复项目}}吗?这将保留每个组中最大的项目并永久删除所有其它重复项目。此操作无法撤消!", + "bulk_delete_duplicates_confirmation": "您确定要批量删除{count, plural, one {#个重复项目} other {#个重复项目}}吗?这将保留每个组中最大的项目并永久删除所有其它重复项目。注意:该操作无法被撤消!", "bulk_keep_duplicates_confirmation": "您确定要保留{count, plural, one {#个重复项目} other {#个重复项目}}吗?这将清空所有重复记录,但不会删除任何内容。", "bulk_trash_duplicates_confirmation": "您确定要批量删除{count, plural, one {#个重复项目} other {#个重复项目}}吗?这将保留每组中最大的项目并删除所有其它重复项目。", "buy": "购买Immich", @@ -417,7 +444,7 @@ "cancel": "取消", "cancel_search": "取消搜索", "cannot_merge_people": "无法合并人物", - "cannot_undo_this_action": "无法撤消此操作!", + "cannot_undo_this_action": "注意:该操作无法被撤消!", "cannot_update_the_description": "无法更新描述", "cant_apply_changes": "无法应用更改", "cant_get_faces": "找不到人脸", @@ -440,10 +467,12 @@ "clear_all": "清空全部", "clear_all_recent_searches": "清除所有最近搜索", "clear_message": "清空消息", - "clear_value": "清空值", + "clear_value": "删除内容", + "clockwise": "顺时针", "close": "关闭", "collapse": "折叠", "collapse_all": "全部折叠", + "color": "颜色", "color_theme": "颜色主题", "comment_deleted": "评论已删除", "comment_options": "评论选项", @@ -454,7 +483,7 @@ "confirm_delete_shared_link": "您确定要删除此共享链接吗?", "confirm_password": "确认密码", "contain": "包含", - "context": "图像语义搜索", + "context": "以文搜图", "continue": "继续", "copied_image_to_clipboard": "已复制图片至剪贴板。", "copied_to_clipboard": "已复制到剪切板!", @@ -477,36 +506,42 @@ "create_new_person": "创建新人物", "create_new_person_hint": "指派已选择项目到新的人物", "create_new_user": "创建新用户", + "create_tag": "创建标签", + "create_tag_description": "创建一个新标签。对于嵌套标签,请输入标签的完整路径,包括正斜杠(/)。", "create_user": "创建用户", "created": "已创建", "current_device": "当前设备", "custom_locale": "自定义地区", "custom_locale_description": "日期和数字显示格式跟随语言和地区", "dark": "深色", - "date_after": "日期之后", + "date_after": "开始日期", "date_and_time": "日期与时间", - "date_before": "日期之前", + "date_before": "结束日期", "date_of_birth_saved": "出生日期保存成功", "date_range": "日期范围", - "day": "天", + "day": "日", "deduplicate_all": "删除所有重复项", "default_locale": "默认地区", "default_locale_description": "根据您的浏览器地区设置日期和数字显示格式", "delete": "删除", "delete_album": "删除相册", - "delete_api_key_prompt": "是否确定删除此API key?", - "delete_duplicates_confirmation": "你是否希望永久删除这些重复项?", - "delete_key": "删除秘钥", + "delete_api_key_prompt": "确定删除此API key吗?", + "delete_duplicates_confirmation": "你要永久删除这些重复项吗?", + "delete_key": "删除密钥", "delete_library": "删除图库", "delete_link": "删除链接", "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": "忽略错误", @@ -515,8 +550,11 @@ "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": "下载中", @@ -546,21 +584,26 @@ "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": "空", "empty_album": "清空相册", "empty_trash": "清空回收站", - "empty_trash_confirmation": "确定要清空回收站?这将从Immich永久移除回收站中的所有项目。\n该操作无法撤消!", + "empty_trash_confirmation": "确定要清空回收站?这将永久删除回收站中的所有项目。\n注意:该操作无法撤消!", "enable": "启用", "enabled": "已启用", "end_date": "结束日期", "error": "错误", "error_loading_image": "加载图片时出错", - "error_title": "错误 - 我们遇到了一些问题", + "error_title": "错误 - 出了点问题", "errors": { "cannot_navigate_next_asset": "无法导航到下一个项目", "cannot_navigate_previous_asset": "无法导航到上一个项目", @@ -572,11 +615,11 @@ "cant_get_number_of_comments": "无法获取评论数量", "cant_search_people": "无法检索人物", "cant_search_places": "无法检索地点", - "cleared_jobs": "已删除作业:{job}", + "cleared_jobs": "已删除任务:{job}", "error_adding_assets_to_album": "添加项目到相册时出错", "error_adding_users_to_album": "添加用户到相册时出错", "error_deleting_shared_user": "删除共享用户时出错", - "error_downloading": "下载{filename}时出错", + "error_downloading": "下载“{filename}”时出错", "error_hiding_buy_button": "隐藏购买按钮时出错", "error_removing_assets_from_album": "从相册中移除项目时出错,请到控制台获取更详细信息", "error_selecting_all_assets": "选择所有项目时出错", @@ -593,7 +636,7 @@ "failed_to_stack_assets": "无法堆叠项目", "failed_to_unstack_assets": "无法取消堆叠项目", "import_path_already_exists": "此导入路径已存在。", - "incorrect_email_or_password": "错误的邮箱或密码", + "incorrect_email_or_password": "邮箱或密码错误", "paths_validation_failed": "{paths, plural, one {#条路径} other {#条路径}} 校验失败", "profile_picture_transparent_pixels": "个人资料图片不可以包含透明像素。请放大或移动此图片。", "quota_higher_than_disk_size": "设置的配额大于磁盘容量", @@ -604,8 +647,8 @@ "unable_to_add_exclusion_pattern": "无法添加排除规则", "unable_to_add_import_path": "无法添加导入路径", "unable_to_add_partners": "无法添加同伴", - "unable_to_add_remove_archive": "无法{archived, select, true {从归档中移除} other {添加项目至归档}}", - "unable_to_add_remove_favorites": "无法{favorite, select, true {添加项目至收藏} other {从收藏中移除}}", + "unable_to_add_remove_archive": "无法{archived, select, true {从归档中移除} other {添加项目到归档}}", + "unable_to_add_remove_favorites": "无法{favorite, select, true {添加项目到收藏} other {从收藏中移除}}", "unable_to_archive_unarchive": "无法{archived, select, true {归档} other {取消归档}}", "unable_to_change_album_user_role": "无法更改相册用户规则", "unable_to_change_date": "无法更改日期", @@ -639,6 +682,7 @@ "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": "无法加载项目活动", @@ -655,10 +699,10 @@ "unable_to_remove_api_key": "无法移除API Key", "unable_to_remove_assets_from_shared_link": "无法从共享链接中移除项目", "unable_to_remove_comment": "无法移除评论", + "unable_to_remove_deleted_assets": "无法移除离线文件", "unable_to_remove_library": "无法移除图库", - "unable_to_remove_offline_files": "无法移除离线文件", "unable_to_remove_partner": "无法移除同伴", - "unable_to_remove_reaction": "无法移除反应", + "unable_to_remove_reaction": "无法移除回应", "unable_to_remove_user": "无法移除用户", "unable_to_repair_items": "无法修复项目", "unable_to_reset_password": "无法重置密码", @@ -676,15 +720,16 @@ "unable_to_scan_library": "无法扫描库", "unable_to_set_feature_photo": "无法设置人物头像", "unable_to_set_profile_picture": "无法设置个人资料图片", - "unable_to_submit_job": "无法提交作业", + "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_timeline_display_status": "无法更新时间线显示状态", + "unable_to_update_timeline_display_status": "无法更新时间轴显示状态", "unable_to_update_user": "无法更新用户", "unable_to_upload_file": "无法上传文件" }, @@ -692,13 +737,14 @@ "every_night_at_midnight": "每天午夜", "every_night_at_twoam": "每天凌晨两点", "every_six_hours": "每6小时", - "exif": "Exif", + "exif": "Exif信息", "exit_slideshow": "退出幻灯片放映", - "expand_all": "展开全部", + "expand_all": "全部展开", "expire_after": "有效期至", "expired": "已过期", "expires_date": "{date}过期", "explore": "探索", + "explorer": "资源管理器", "export": "导出", "export_as_json": "导出为JSON", "extension": "扩展", @@ -712,6 +758,8 @@ "feature": "功能", "feature_photo_updated": "人物头像已更新", "featurecollection": "功能合集", + "features": "功能", + "features_setting_description": "管理App功能", "file_name": "文件名", "file_name_or_extension": "文件名或扩展名", "filename": "文件名", @@ -720,6 +768,8 @@ "filter_people": "过滤人物", "find_them_fast": "按名称快速搜索", "fix_incorrect_match": "修复不正确的匹配", + "folders": "文件夹", + "folders_feature_description": "在文件夹视图中浏览文件系统上的照片和视频", "force_re-scan_library_files": "强制重新扫描所有图库文件", "forward": "向前", "general": "通用", @@ -732,15 +782,15 @@ "group_no": "未分组", "group_owner": "按所有者分组", "group_year": "按年分组", - "has_quota": "有限额", + "has_quota": "配额大小", "hi_user": "你好,{name}({email})", "hide_all_people": "隐藏所有人物", "hide_gallery": "隐藏相册", - "hide_named_person": "隐藏人物{name}", + "hide_named_person": "隐藏人物“{name}”", "hide_password": "隐藏密码", "hide_person": "隐藏人物", "hide_unnamed_people": "隐藏未命名的人物", - "host": "主机", + "host": "服务器", "hour": "时", "image": "图片", "image_alt_text_date": "在{date}拍摄的{isVideo, select, true {视频} other {照片}}", @@ -758,13 +808,13 @@ "image_taken": "{isVideo, select, true {选择视频} other {选择图片}}", "img": "图片", "immich_logo": "Immich Logo", - "immich_web_interface": "Immich Web接口", + "immich_web_interface": "Immich Web界面", "import_from_json": "从JSON导入", "import_path": "导入路径", "in_albums": "在{count, plural, one {#个相册} other {#个相册}}中", "in_archive": "在归档中", "include_archived": "包括已归档", - "include_shared_albums": "包含共享相册", + "include_shared_albums": "包括共享相册", "include_shared_partner_assets": "包括同伴共享项目", "individual_share": "个人分享", "info": "信息", @@ -780,7 +830,7 @@ "job_settings_description": "管理任务并发", "jobs": "任务", "keep": "保留", - "keep_all": "保留", + "keep_all": "保留所有", "keyboard_shortcuts": "键盘快捷键", "language": "语言", "language_setting_description": "选择您的语言偏好", @@ -819,6 +869,7 @@ "license_trial_info_4": "请考虑购买授权来支持此服务的持续开发", "light": "浅色", "like_deleted": "已删除的收藏", + "link_motion_video": "链接动态视频", "link_options": "链接选项", "link_to_oauth": "链接到OAuth", "linked_oauth_account": "绑定OAuth账户", @@ -826,17 +877,18 @@ "loading": "加载中", "loading_search_results_failed": "加载搜索结果失败", "log_out": "注销", - "log_out_all_devices": "登出所有设备", - "logged_out_all_devices": "从所有设备登出", - "logged_out_device": "从设备登出", + "log_out_all_devices": "注销所有设备", + "logged_out_all_devices": "从所有设备注销", + "logged_out_device": "从设备注销", "login": "登录", "login_has_been_disabled": "登录已禁用。", - "logout_all_device_confirmation": "确定要从所有设备登出?", - "logout_this_device_confirmation": "确定要从本设备登出?", + "logout_all_device_confirmation": "确定要从所有设备注销?", + "logout_this_device_confirmation": "确定要从本设备注销?", "longitude": "经度", - "look": "查看", + "look": "样式", "loop_videos": "循环视频", "loop_videos_description": "启用在详细信息中自动循环播放视频。", + "main_branch_warning": "您当前使用的是开发版;我们强烈建议您使用正式发行版(release版)!", "make": "品牌", "manage_shared_links": "管理共享链接", "manage_sharing_with_partners": "管理与同伴的共享", @@ -859,7 +911,7 @@ "merge": "合并", "merge_people": "合并人物", "merge_people_limit": "每次最多只能合并 5 个人", - "merge_people_prompt": "你想合并这些人吗?此操作不可逆转。", + "merge_people_prompt": "你想合并这些人吗?该操作不可逆(无法被撤销)。", "merge_people_successfully": "合并人物成功", "merged_people_count": "已合并{count, plural, one {#个人} other {#个人}}", "minimize": "最小化", @@ -872,7 +924,7 @@ "my_albums": "我的相册", "name": "名称", "name_or_nickname": "名称或昵称", - "never": "从不", + "never": "永不过期", "new_album": "新相册", "new_api_key": "新API Key", "new_password": "新密码", @@ -889,7 +941,7 @@ "no_archived_assets_message": "归档照片和视频以便在照片视图中隐藏它们", "no_assets_message": "点击上传您的第一张照片", "no_duplicates_found": "未发现重复项。", - "no_exif_info_available": "没有可用的exif信息", + "no_exif_info_available": "没有可用的EXIF信息", "no_explore_results_message": "上传更多照片来探索。", "no_favorites_message": "添加到收藏夹,快速查找最佳图片和视频", "no_libraries_message": "创建外部图库来查看你的照片和视频", @@ -899,25 +951,28 @@ "no_results_description": "尝试使用同义词或更通用的关键词", "no_shared_albums_message": "创建相册以共享照片和视频", "not_in_any_album": "不在任何相册中", - "note_apply_storage_label_to_previously_uploaded assets": "提示:要将存储标签应用于之前上传的项目,运行以下命令", - "note_unlimited_quota": "注:输入 0 表示无限制配额", + "note_apply_storage_label_to_previously_uploaded assets": "提示:要将存储标签应用于之前上传的项目,需要运行", + "note_unlimited_quota": "注:输入 0 表示无限配额", "notes": "提示", "notification_toggle_setting_description": "启用邮件通知", "notifications": "通知", "notifications_setting_description": "管理通知", "oauth": "OAuth", + "official_immich_resources": "Immich 官方资源", "offline": "离线", "offline_paths": "离线文件", "offline_paths_description": "这些结果可能是由于手动删除了不属于外部图库的文件造成的。", "ok": "确定", "oldest_first": "最旧优先", "onboarding": "盛大开启", + "onboarding_privacy_description": "以下(可选)功能依赖外部服务,可随时在管理设置中禁用。", "onboarding_theme_description": "选择服务的颜色主题。稍后可以在设置中进行修改。", "onboarding_welcome_description": "我们在启用服务前先做一些通用设置。", - "onboarding_welcome_user": "欢迎,{user}", + "onboarding_welcome_user": "欢迎你,{user}", "online": "在线", "only_favorites": "仅显示已收藏", "only_refreshes_modified_files": "仅刷新修改的文件", + "open_in_map_view": "在地图视图中打开", "open_in_openstreetmap": "在OpenStreetMap中打开", "open_the_search_filters": "打开搜索过滤器", "options": "选项", @@ -952,13 +1007,14 @@ "pending": "待处理", "people": "人物", "people_edits_count": "{count, plural, one {#个人物} other {#个人物}}已编辑", - "people_sidebar_description": "在侧边栏中显示指向人物的链接", + "people_feature_description": "按人物分组进行浏览照片和视频", + "people_sidebar_description": "在侧边栏中显示“人物”链接", "perform_library_tasks": "", "permanent_deletion_warning": "永久删除警告", "permanent_deletion_warning_setting_description": "当永久删除项目时显示警告", "permanently_delete": "永久删除", - "permanently_delete_assets_count": "{count, plural, one {个项目} other {个项目}}已删除", - "permanently_delete_assets_prompt": "确定要永久删除{count, plural, one {此项目} other {这#个项目}}?该操作会同时将{count, plural, one {它} other {它们}}从其所在相册中移除。", + "permanently_delete_assets_count": "永久删除{count, plural, one {项目} other {项目}}", + "permanently_delete_assets_prompt": "确定要永久删除 {count, plural, one {此项目?} other {这#个项目?}} 该操作会同时将 {count, plural, one {它} other {它们}} 从其所在相册中移除。", "permanently_deleted_asset": "永久删除的项目", "permanently_deleted_assets": "永久删除{count, plural, one {# 个项目} other {# 个项目}}", "permanently_deleted_assets_count": "{count, plural, one {#个项目} other {#个项目}}已删除", @@ -984,11 +1040,12 @@ "previous_memory": "上一个", "previous_or_next_photo": "上一张或下一张照片", "primary": "首要", + "privacy": "隐私", "profile_image_of_user": "{user}的个人资料图片", "profile_picture_set": "个人资料图片已设置。", "public_album": "公开相册", "public_share": "公开共享", - "purchase_account_info": "Supporter", + "purchase_account_info": "支持者", "purchase_activated_subtitle": "感谢您对 Immich 和开源软件的支持", "purchase_activated_time": "激活于{date, date}", "purchase_activated_title": "您的密钥已成功激活", @@ -1001,10 +1058,10 @@ "purchase_button_select": "选择", "purchase_failed_activation": "激活失败!请检查您的邮箱以获取正确的产品密钥!", "purchase_individual_description_1": "适用于个人", - "purchase_individual_description_2": "Supporter 状态", + "purchase_individual_description_2": "支持者状态", "purchase_individual_title": "个人", "purchase_input_suggestion": "已有一个产品密钥?请在下方输入密钥", - "purchase_license_subtitle": "购买 Immich 以支持此项目的持续发展", + "purchase_license_subtitle": "购买 Immich 以支持此服务的持续发展", "purchase_lifetime_description": "终身许可", "purchase_option_title": "购买选项", "purchase_panel_info_1": "开发 Immich 需要大量的时间和精力,我们有全职工程师在努力将其做到最好。我们的使命是通过开源软件和道德商业实践,为开发者提供可持续的收入来源,并创建一个尊重隐私的生态系统,提供一个可以真正替代现有剥削性云服务的选择。", @@ -1017,12 +1074,16 @@ "purchase_remove_server_product_key": "移除服务器产品密钥", "purchase_remove_server_product_key_prompt": "您确定要删除服务器产品密钥吗?", "purchase_server_description_1": "适用于整个服务器", - "purchase_server_description_2": "Supporter 状态", + "purchase_server_description_2": "支持者状态", "purchase_server_title": "服务器", "purchase_settings_server_activated": "服务器产品密钥正在由管理员管理", "range": "范围", + "rating": "星级", + "rating_clear": "删除星级", + "rating_count": "{count, plural, one {#星} other {#星}}", + "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", - "reaction_options": "反应选项", + "reaction_options": "回应选项", "read_changelog": "阅读更新日志", "reassign": "重新指派", "reassigned_assets_to_existing_person": "重新指派{count, plural, one {#个项目} other {#个项目}}到{name, select, null {已存在的人物} other {{name}}}", @@ -1032,27 +1093,30 @@ "recent_searches": "最近搜索", "refresh": "刷新", "refresh_encoded_videos": "刷新已编码的视频", + "refresh_faces": "刷新人脸", "refresh_metadata": "刷新元数据", "refresh_thumbnails": "刷新缩略图", "refreshed": "已刷新", - "refreshes_every_file": "刷新全部文件", + "refreshes_every_file": "重新扫描所有现有文件和新文件", "refreshing_encoded_video": "正在刷新已编码视频", + "refreshing_faces": "正在刷新人脸", "refreshing_metadata": "正在刷新元数据", "regenerating_thumbnails": "正在重新生成缩略图", "remove": "移除", "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_custom_date_range": "取消自定义日期范围", + "remove_deleted_assets": "删除离线文件", "remove_from_album": "从相册中移除", "remove_from_favorites": "移出收藏", "remove_from_shared_link": "从共享链接中移除", - "remove_offline_files": "删除离线文件", "remove_user": "移除用户", "removed_api_key": "移除的API Key:{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": "未跟踪和缺失的文件将在此处显示", @@ -1065,7 +1129,7 @@ "reset_people_visibility": "重置人物可见性", "reset_settings_to_default": "恢复到默认设置", "reset_to_default": "恢复默认值", - "resolve_duplicates": "处理查复项", + "resolve_duplicates": "处理重复项", "resolved_all_duplicates": "解决所有重复问题", "restore": "恢复", "restore_all": "恢复所有", @@ -1084,6 +1148,7 @@ "say_something": "说点什么", "scan_all_libraries": "扫描所有图库", "scan_all_library_files": "重新扫描所有图库文件", + "scan_library": "扫描", "scan_new_library_files": "扫描新的图库文件", "scan_settings": "扫描设置", "scanning_for_album": "扫描相册中...", @@ -1098,13 +1163,16 @@ "search_country": "搜索国家...", "search_for_existing_person": "搜索已有人物", "search_no_people": "找不到人物", - "search_no_people_named": "人物姓名“{name}”不存在", + "search_no_people_named": "人物“{name}”不存在", + "search_options": "搜索选项", "search_people": "搜索人物", "search_places": "搜索地点", + "search_settings": "搜索设置", "search_state": "搜索省份...", + "search_tags": "搜索标签…", "search_timezone": "搜索时区...", "search_type": "搜索类型", - "search_your_photos": "搜索你的照片", + "search_your_photos": "搜索您的照片", "searching_locales": "搜索地区...", "second": "秒", "see_all_people": "查看所有人物", @@ -1140,17 +1208,19 @@ "share": "共享", "shared": "共享", "shared_by": "共享自", - "shared_by_user": "由{user}共享", + "shared_by_user": "由“{user}”共享", "shared_by_you": "你的共享", - "shared_from_partner": "来自{partner}的照片", + "shared_from_partner": "来自“{partner}”的照片", + "shared_link_options": "共享链接选项", "shared_links": "共享链接", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", - "shared_with_partner": "与{partner}共享", + "shared_with_partner": "与“{partner}”共享", "sharing": "共享", "sharing_enter_password": "请输入密码后查看此页面。", - "sharing_sidebar_description": "在侧边栏中显示共享链接", - "shift_to_permanent_delete": "按⇧永久删除项目", + "sharing_sidebar_description": "在侧边栏中显示“共享”链接", + "shift_to_permanent_delete": "按住Shift键永久删除项目", "show_album_options": "显示相册选项", + "show_albums": "显示相册", "show_all_people": "显示所有人物", "show_and_hide_people": "显示和隐藏人物", "show_file_location": "显示文件位置", @@ -1165,13 +1235,18 @@ "show_person_options": "显示人物选项", "show_progress_bar": "显示进度条", "show_search_options": "显示搜索选项", - "show_supporter_badge": "Supporter 徽章", - "show_supporter_badge_description": "展示 Supporter 徽章", + "show_slideshow_transition": "显示幻灯片过渡效果", + "show_supporter_badge": "支持者徽章", + "show_supporter_badge_description": "展示支持者徽章", "shuffle": "随机", - "sign_out": "登出", + "sidebar": "侧边栏", + "sidebar_display_description": "在侧边栏中显示链接", + "sign_out": "注销", "sign_up": "注册", "size": "大小", - "skip_to_content": "跳到内容", + "skip_to_content": "跳转到内容", + "skip_to_folders": "跳转到文件夹", + "skip_to_tags": "跳转到标签", "slideshow": "幻灯片放映", "slideshow_settings": "放映设置", "sort_albums_by": "相册排序依据...", @@ -1183,8 +1258,10 @@ "sort_title": "标题", "source": "源", "stack": "堆叠", + "stack_duplicates": "堆叠重复项目", + "stack_select_one_photo": "为堆叠选择一张展示图", "stack_selected_photos": "堆叠选定的照片", - "stacked_assets_count": "已归档{count, plural, one {#个项目} other {#个项目}}", + "stacked_assets_count": "已堆叠{count, plural, one {#个项目} other {#个项目}}", "stacktrace": "堆栈跟踪", "start": "开始", "start_date": "开始日期", @@ -1192,30 +1269,44 @@ "status": "状态", "stop_motion_photo": "定格照片", "stop_photo_sharing": "停止共享照片?", - "stop_photo_sharing_description": "{partner}将不能访问你的照片。", + "stop_photo_sharing_description": "“{partner}”将不能访问你的照片。", "stop_sharing_photos_with_user": "停止与此用户共享照片", "storage": "存储空间", "storage_label": "存储标签", - "storage_usage": "{available}中{used}已使用", + "storage_usage": "已用:{used}/{available}", "submit": "提交", "suggestions": "建议", "sunrise_on_the_beach": "海滩上的日出", - "swap_merge_direction": "交换合并方向", + "support": "支持", + "support_and_feedback": "支持和反馈", + "support_third_party_description": "您的 Immich 安装程序是由第三方打包的。您遇到的问题可能是由软件包引起的,所以首先请使用下面的链接提出问题或BUG。", + "swap_merge_direction": "互换合并方向", "sync": "同步", + "tag": "标签", + "tag_assets": "标记项目", + "tag_created": "已创建标签:{tag}", + "tag_feature_description": "按逻辑标签主题分组进行浏览照片和视频", + "tag_not_found_question": "找不到标签吗?创建新标签", + "tag_updated": "已更新标签:{tag}", + "tagged_assets": "{count, plural, one {# 个项目} other {# 个项目}}被加上标签", + "tags": "标签", "template": "模版", "theme": "主题", "theme_selection": "主题选项", - "theme_selection_description": "根据浏览器的系统首选项自动设置主题色", + "theme_selection_description": "跟随浏览器自动设置主题颜色", "they_will_be_merged_together": "项目将会合并到一起", + "third_party_resources": "第三方资源", "time_based_memories": "基于时间的回忆", "timezone": "时区", "to_archive": "归档", "to_change_password": "修改密码", "to_favorite": "收藏", "to_login": "登录", + "to_parent": "返回上一级", + "to_root": "返回到根目录", "to_trash": "放入回收站", "toggle_settings": "切换设置", - "toggle_theme": "切换主题", + "toggle_theme": "切换深色主题", "toggle_visibility": "切换可见性", "total_usage": "总用量", "trash": "回收站", @@ -1234,11 +1325,13 @@ "unknown_album": "未知相册", "unknown_year": "未知年份", "unlimited": "无限制", + "unlink_motion_video": "取消链接动态视频", "unlink_oauth": "解绑OAuth", "unlinked_oauth_account": "解绑OAuth账户", "unnamed_album": "未命名相册", + "unnamed_album_delete_confirmation": "您确定要删除该相册吗?", "unnamed_share": "未命名共享", - "unsaved_change": "未保存的修改", + "unsaved_change": "修改未保存", "unselect_all": "取消全选", "unselect_all_duplicates": "取消选择所有重复项", "unstack": "取消堆叠", @@ -1258,15 +1351,15 @@ "upload_success": "上传成功,刷新页面查看新上传的项目。", "url": "URL", "usage": "用量", - "use_custom_date_range": "使用自定义日期范围", + "use_custom_date_range": "自定义日期范围", "user": "用户", "user_id": "用户ID", "user_license_settings": "授权", "user_license_settings_description": "管理你的授权", - "user_liked": "{user}点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", + "user_liked": "“{user}”点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", "user_purchase_settings": "购买", "user_purchase_settings_description": "管理购买订单", - "user_role_set": "设置{user}为{role}", + "user_role_set": "设置“{user}”为“{role}”", "user_usage_detail": "用户用量详情", "username": "用户名", "users": "用户", @@ -1275,7 +1368,9 @@ "variables": "变量", "version": "版本", "version_announcement_closing": "你的朋友,Alex", - "version_announcement_message": "嗨,伙计,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在错误配置,特别是当你是使用WatchTower或其它类似的自动升级工具时。", + "version_announcement_message": "嗨,朋友,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在配置错误,特别是当你是使用WatchTower或其它类似的自动升级工具时。", + "version_history": "版本历史", + "version_history_item": "在 {date} 安装版本 {version}", "video": "视频", "video_hover_setting": "鼠标悬停时播放视频缩略图", "video_hover_setting_description": "当鼠标悬停在项目上时播放视频缩略图。即使禁用了这个功能,也可以通过将鼠标悬停在播放图标上来开始播放。", @@ -1285,17 +1380,18 @@ "view_album": "查看相册", "view_all": "查看全部", "view_all_users": "查看全部用户", + "view_in_timeline": "在时间轴中查看", "view_links": "查看链接", "view_next_asset": "查看下一项", "view_previous_asset": "查看上一项", "view_stack": "查看堆叠项目", "viewer": "预览", "visibility_changed": "{count, plural, one {#个人物} other {#个人物}}的可见性已修改", - "waiting": "队列中", + "waiting": "准备处理", "warning": "警告", "week": "周", "welcome": "欢迎", - "welcome_to_immich": "欢迎使用immich", + "welcome_to_immich": "欢迎使用 Immich", "year": "年", "years_ago": "{years, plural, one {#年} other {#年}}前", "yes": "是", diff --git a/localizely.yml b/localizely.yml index 343464284a..5d119fe9d8 100644 --- a/localizely.yml +++ b/localizely.yml @@ -52,7 +52,7 @@ download: locale_code: nb-NO - file: mobile/assets/i18n/sv-SE.json locale_code: sv-SE - - file: mobile/assets/i18n/mn.json + - file: mobile/assets/i18n/mn-MN.json locale_code: mn - file: mobile/assets/i18n/ko-KR.json locale_code: ko-KR diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 87c930f1ce..fa654d70b7 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,12 +1,12 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:ef4b550f029a76b94f8e6cc6e4a8ed0e870fc6c5af1c4e9d77faaea50f41f6cd as builder-cpu +FROM python:3.11-bookworm@sha256:70f1eb2927a8ef72840254b17024d3a8aa8c3c9715a625d426a2861b5899bc62 AS builder-cpu -FROM builder-cpu as builder-openvino +FROM builder-cpu AS builder-openvino -FROM builder-cpu as builder-cuda +FROM builder-cpu AS builder-cuda -FROM builder-cpu as builder-armnn +FROM builder-cpu AS builder-armnn ENV ARMNN_PATH=/opt/armnn COPY ann /opt/ann @@ -15,7 +15,7 @@ RUN mkdir /opt/armnn && \ cd /opt/ann && \ sh build.sh -FROM builder-${DEVICE} as builder +FROM builder-${DEVICE} AS builder ARG DEVICE ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -34,23 +34,33 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ee317183d292ee6ed30e90bc325043ca3f7d2e8c79ac5019575490b5256ae244 as prod-cpu +FROM python:3.11-slim-bookworm@sha256:5148c0e4bbb64271bca1d3322360ebf4bfb7564507ae32dd639322e4952a6b16 AS prod-cpu -FROM prod-cpu as prod-openvino +FROM prod-cpu AS prod-openvino -COPY scripts/configure-apt.sh ./ -RUN ./configure-apt.sh && \ - apt-get update && \ - apt-get install -t unstable --no-install-recommends -yqq intel-opencl-icd && \ - rm configure-apt.sh +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ + dpkg -i *.deb && \ + rm *.deb && \ + apt-get remove wget -yqq && \ + rm -rf /var/lib/apt/lists/* -FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 as prod-cuda +FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04@sha256:94c1577b2cd9dd6c0312dc04dff9cb2fdce2b268018abc3d7c2dbcacf1155000 AS prod-cuda + +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq libcudnn9-cuda-12 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so -FROM prod-cpu as prod-armnn +FROM prod-cpu AS prod-armnn ENV LD_LIBRARY_PATH=/opt/armnn @@ -70,7 +80,7 @@ COPY --from=builder-armnn \ /opt/ann/build.sh \ /opt/armnn/ -FROM prod-${DEVICE} as prod +FROM prod-${DEVICE} AS prod ARG DEVICE RUN apt-get update && \ @@ -94,7 +104,7 @@ RUN echo "hard core 0" >> /etc/security/limits.conf && \ COPY --from=builder /opt/venv /opt/venv COPY ann/ann.py /usr/src/ann/ann.py -COPY start.sh log_conf.json ./ +COPY start.sh log_conf.json gunicorn_conf.py ./ COPY app . ENTRYPOINT ["tini", "--"] CMD ["./start.sh"] diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index af2d0aa4b9..92799ac692 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -6,7 +6,8 @@ from pathlib import Path from socket import socket from gunicorn.arbiter import Arbiter -from pydantic import BaseModel, BaseSettings +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console from rich.logging import RichHandler from uvicorn import Server @@ -14,11 +15,22 @@ from uvicorn.workers import UvicornWorker class PreloadModelData(BaseModel): - clip: str | None - facial_recognition: str | None + clip: str | None = None + facial_recognition: str | None = None + + +class MaxBatchSize(BaseModel): + facial_recognition: int | None = None class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="MACHINE_LEARNING_", + case_sensitive=False, + env_nested_delimiter="__", + protected_namespaces=("settings_",), + ) + cache_folder: Path = Path("/cache") model_ttl: int = 300 model_ttl_poll_s: int = 10 @@ -33,20 +45,19 @@ class Settings(BaseSettings): ann_fp16_turbo: bool = False ann_tuning_level: int = 2 preload: PreloadModelData | None = None + max_batch_size: MaxBatchSize | None = None - class Config: - env_prefix = "MACHINE_LEARNING_" - case_sensitive = False - env_nested_delimiter = "__" + @property + def device_id(self) -> str: + return os.environ.get("MACHINE_LEARNING_DEVICE_ID", "0") class LogSettings(BaseSettings): + model_config = SettingsConfigDict(case_sensitive=False) + immich_log_level: str = "info" no_color: bool = False - class Config: - case_sensitive = False - _clean_name = str.maketrans(":\\/", "___", ".") diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index 000119937e..684001b875 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -12,7 +12,7 @@ from zipfile import BadZipFile import orjson from fastapi import Depends, FastAPI, File, Form, HTTPException -from fastapi.responses import ORJSONResponse +from fastapi.responses import ORJSONResponse, PlainTextResponse from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from PIL.Image import Image from pydantic import ValidationError @@ -28,14 +28,12 @@ from .schemas import ( InferenceEntries, InferenceEntry, InferenceResponse, - MessageResponse, ModelFormat, ModelIdentity, ModelTask, ModelType, PipelineRequest, T, - TextResponse, ) MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger @@ -127,14 +125,14 @@ def get_entries(entries: str = Form()) -> InferenceEntries: app = FastAPI(lifespan=lifespan) -@app.get("/", response_model=MessageResponse) -async def root() -> dict[str, str]: - return {"message": "Immich ML"} +@app.get("/") +async def root() -> ORJSONResponse: + return ORJSONResponse({"message": "Immich ML"}) -@app.get("/ping", response_model=TextResponse) -def ping() -> str: - return "pong" +@app.get("/ping") +def ping() -> PlainTextResponse: + return PlainTextResponse("pong") @app.post("/predict", dependencies=[Depends(update_state)]) diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py index 1c019969b4..3bbd1a0289 100644 --- a/machine-learning/app/models/base.py +++ b/machine-learning/app/models/base.py @@ -71,7 +71,6 @@ class InferenceModel(ABC): f"immich-app/{clean_name(self.model_name)}", cache_dir=self.cache_dir, local_dir=self.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=ignore_patterns, ) diff --git a/machine-learning/app/models/clip/textual.py b/machine-learning/app/models/clip/textual.py index 7a25c2f4ad..32c28ea2bb 100644 --- a/machine-learning/app/models/clip/textual.py +++ b/machine-learning/app/models/clip/textual.py @@ -10,6 +10,7 @@ from tokenizers import Encoding, Tokenizer from app.config import log from app.models.base import InferenceModel +from app.models.transforms import clean_text from app.schemas import ModelSession, ModelTask, ModelType @@ -25,6 +26,8 @@ class BaseCLIPTextualEncoder(InferenceModel): session = super()._load() log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'") self.tokenizer = self._load_tokenizer() + tokenizer_kwargs: dict[str, Any] | None = self.text_cfg.get("tokenizer_kwargs") + self.canonicalize = tokenizer_kwargs is not None and tokenizer_kwargs.get("clean") == "canonicalize" log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'") return session @@ -56,6 +59,11 @@ class BaseCLIPTextualEncoder(InferenceModel): log.debug(f"Loaded model config for CLIP model '{self.model_name}'") return model_cfg + @property + def text_cfg(self) -> dict[str, Any]: + text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] + return text_cfg + @cached_property def tokenizer_file(self) -> dict[str, Any]: log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'") @@ -73,8 +81,7 @@ class BaseCLIPTextualEncoder(InferenceModel): class OpenClipTextualEncoder(BaseCLIPTextualEncoder): def _load_tokenizer(self) -> Tokenizer: - text_cfg: dict[str, Any] = self.model_cfg["text_cfg"] - context_length: int = text_cfg.get("context_length", 77) + context_length: int = self.text_cfg.get("context_length", 77) pad_token: str = self.tokenizer_cfg["pad_token"] tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix()) @@ -86,12 +93,14 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder): return tokenizer def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return {"text": np.array([tokens.ids], dtype=np.int32)} class MClipTextualEncoder(OpenClipTextualEncoder): def tokenize(self, text: str) -> dict[str, NDArray[np.int32]]: + text = clean_text(text, canonicalize=self.canonicalize) tokens: Encoding = self.tokenizer.encode(text) return { "input_ids": np.array([tokens.ids], dtype=np.int32), diff --git a/machine-learning/app/models/facial_recognition/recognition.py b/machine-learning/app/models/facial_recognition/recognition.py index d9ceb12b6d..dcfb6b530e 100644 --- a/machine-learning/app/models/facial_recognition/recognition.py +++ b/machine-learning/app/models/facial_recognition/recognition.py @@ -3,17 +3,17 @@ from typing import Any import numpy as np import onnx +import onnxruntime as ort from insightface.model_zoo import ArcFaceONNX from insightface.utils.face_align import norm_crop from numpy.typing import NDArray from onnx.tools.update_model_dims import update_inputs_outputs_dims from PIL import Image -from app.config import log +from app.config import log, settings from app.models.base import InferenceModel from app.models.transforms import decode_cv2 from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType -from app.sessions import has_batch_axis class FaceRecognizer(InferenceModel): @@ -23,11 +23,12 @@ class FaceRecognizer(InferenceModel): def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) -> None: super().__init__(model_name, **model_kwargs) self.min_score = model_kwargs.pop("minScore", min_score) - self.batch = self.model_format == ModelFormat.ONNX + 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 def _load(self) -> ModelSession: session = self._make_session(self.model_path) - if self.batch and not has_batch_axis(session): + if (not self.batch_size or self.batch_size > 1) and str(session.get_inputs()[0].shape[0]) != "batch": self._add_batch_axis(self.model_path) session = self._make_session(self.model_path) self.model = ArcFaceONNX( @@ -43,18 +44,18 @@ class FaceRecognizer(InferenceModel): return [] inputs = decode_cv2(inputs) cropped_faces = self._crop(inputs, faces) - embeddings = self._predict_batch(cropped_faces) if self.batch else self._predict_single(cropped_faces) + embeddings = self._predict_batch(cropped_faces) return self.postprocess(faces, embeddings) def _predict_batch(self, cropped_faces: list[NDArray[np.uint8]]) -> NDArray[np.float32]: - embeddings: NDArray[np.float32] = self.model.get_feat(cropped_faces) - return embeddings + if not self.batch_size or len(cropped_faces) <= self.batch_size: + embeddings: NDArray[np.float32] = self.model.get_feat(cropped_faces) + return embeddings - def _predict_single(self, cropped_faces: list[NDArray[np.uint8]]) -> NDArray[np.float32]: - embeddings: list[NDArray[np.float32]] = [] - for face in cropped_faces: - embeddings.append(self.model.get_feat(face)) - return np.concatenate(embeddings, axis=0) + batch_embeddings: list[NDArray[np.float32]] = [] + for i in range(0, len(cropped_faces), self.batch_size): + batch_embeddings.append(self.model.get_feat(cropped_faces[i : i + self.batch_size])) + return np.concatenate(batch_embeddings, axis=0) def postprocess(self, faces: FaceDetectionOutput, embeddings: NDArray[np.float32]) -> FacialRecognitionOutput: return [ @@ -78,3 +79,8 @@ class FaceRecognizer(InferenceModel): output_dims = {proto.graph.output[0].name: ["batch"] + static_output_dims} updated_proto = update_inputs_outputs_dims(proto, input_dims, output_dims) onnx.save(updated_proto, model_path) + + @property + def _batch_size_default(self) -> int | None: + providers = ort.get_available_providers() + return None if self.model_format == ModelFormat.ONNX and "OpenVINOExecutionProvider" not in providers else 1 diff --git a/machine-learning/app/models/transforms.py b/machine-learning/app/models/transforms.py index cae9b6b1ab..bb03103d4b 100644 --- a/machine-learning/app/models/transforms.py +++ b/machine-learning/app/models/transforms.py @@ -1,3 +1,4 @@ +import string from io import BytesIO from typing import IO @@ -7,6 +8,7 @@ from numpy.typing import NDArray from PIL import Image _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling} +_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) def resize_pil(img: Image.Image, size: int) -> Image.Image: @@ -60,3 +62,10 @@ def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[ if isinstance(image_bytes, Image.Image): return pil_to_cv2(image_bytes) return image_bytes + + +def clean_text(text: str, canonicalize: bool = False) -> str: + text = " ".join(text.split()) + if canonicalize: + text = text.translate(_PUNCTUATION_TRANS).lower() + return text diff --git a/machine-learning/app/schemas.py b/machine-learning/app/schemas.py index f051db12c3..a7ce2ee60d 100644 --- a/machine-learning/app/schemas.py +++ b/machine-learning/app/schemas.py @@ -1,9 +1,9 @@ from enum import Enum -from typing import Any, Literal, Protocol, TypedDict, TypeGuard, TypeVar +from typing import Any, Literal, Protocol, TypeGuard, TypeVar import numpy as np import numpy.typing as npt -from pydantic import BaseModel +from typing_extensions import TypedDict class StrEnum(str, Enum): @@ -13,14 +13,6 @@ class StrEnum(str, Enum): return self.value -class TextResponse(BaseModel): - __root__: str - - -class MessageResponse(BaseModel): - message: str - - class BoundingBox(TypedDict): x1: int y1: int diff --git a/machine-learning/app/sessions/__init__.py b/machine-learning/app/sessions/__init__.py index e0c00ea4a0..e69de29bb2 100644 --- a/machine-learning/app/sessions/__init__.py +++ b/machine-learning/app/sessions/__init__.py @@ -1,5 +0,0 @@ -from app.schemas import ModelSession - - -def has_batch_axis(session: ModelSession) -> bool: - return not isinstance(session.get_inputs()[0].shape[0], int) or session.get_inputs()[0].shape[0] < 0 diff --git a/machine-learning/app/sessions/ort.py b/machine-learning/app/sessions/ort.py index 1a244b7c57..00c7ad50a9 100644 --- a/machine-learning/app/sessions/ort.py +++ b/machine-learning/app/sessions/ort.py @@ -86,11 +86,13 @@ class OrtSession: provider_options = [] for provider in self.providers: match provider: - case "CPUExecutionProvider" | "CUDAExecutionProvider": + case "CPUExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested"} + case "CUDAExecutionProvider": + options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} case "OpenVINOExecutionProvider": options = { - "device_type": "GPU", + "device_type": f"GPU.{settings.device_id}", "precision": "FP32", "cache_dir": (self.model_path.parent / "openvino").as_posix(), } diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index fb3542e7e4..e5cb63997c 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -124,7 +124,6 @@ class TestBase: "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=["*.armnn"], ) @@ -136,7 +135,6 @@ class TestBase: "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=[], ) @@ -212,10 +210,24 @@ class TestOrtSession: session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert session.provider_options == [ - {"device_type": "GPU", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, + {"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, {"arena_extend_strategy": "kSameAsRequested"}, ] + def test_sets_device_id_for_openvino(self) -> None: + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + + session = OrtSession("ViT-B-32__openai", providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options[0]["device_type"] == "GPU.1" + + def test_sets_device_id_for_cuda(self) -> None: + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + + session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider"]) + + assert session.provider_options[0]["device_id"] == "1" + def test_sets_provider_options_kwarg(self) -> None: session = OrtSession( "ViT-B-32__openai", @@ -379,13 +391,40 @@ class TestCLIP: clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") clip_encoder._load() - tokens = clip_encoder.tokenize("test search query") + tokens = clip_encoder.tokenize("test search query") assert "text" in tokens assert isinstance(tokens["text"], np.ndarray) assert tokens["text"].shape == (1, 77) assert tokens["text"].dtype == np.int32 assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") + + def test_openclip_tokenizer_canonicalizes_text( + self, + mocker: MockerFixture, + clip_model_cfg: dict[str, Any], + clip_tokenizer_cfg: Callable[[Path], dict[str, Any]], + ) -> None: + clip_model_cfg["text_cfg"]["tokenizer_kwargs"] = {"clean": "canonicalize"} + mocker.patch.object(OpenClipTextualEncoder, "download") + mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg) + mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg) + mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value + mock_tokenizer = mocker.patch("app.models.clip.textual.Tokenizer.from_file", autospec=True).return_value + mock_ids = [randint(0, 50000) for _ in range(77)] + mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids) + + clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") + clip_encoder._load() + tokens = clip_encoder.tokenize("Test Search Query!") + + assert "text" in tokens + assert isinstance(tokens["text"], np.ndarray) + assert tokens["text"].shape == (1, 77) + assert tokens["text"].dtype == np.int32 + assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0) + mock_tokenizer.encode.assert_called_once_with("test search query") def test_mclip_tokenizer( self, @@ -510,7 +549,7 @@ class TestFaceRecognition: face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() - assert face_recognizer.batch is True + assert face_recognizer.batch_size is None update_dims.assert_called_once_with(proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]}) onnx.save.assert_called_once_with(update_dims.return_value, face_recognizer.model_path) @@ -533,7 +572,7 @@ class TestFaceRecognition: face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() - assert face_recognizer.batch is True + assert face_recognizer.batch_size is None update_dims.assert_not_called() onnx.load.assert_not_called() onnx.save.assert_not_called() @@ -557,7 +596,33 @@ class TestFaceRecognition: face_recognizer = FaceRecognizer("buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path) face_recognizer.load() - assert face_recognizer.batch is False + assert face_recognizer.batch_size == 1 + update_dims.assert_not_called() + onnx.load.assert_not_called() + onnx.save.assert_not_called() + + def test_recognition_does_not_add_batch_axis_for_openvino( + self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + ) -> None: + onnx = mocker.patch("app.models.facial_recognition.recognition.onnx", autospec=True) + update_dims = mocker.patch( + "app.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True + ) + mocker.patch("app.models.base.InferenceModel.download") + mocker.patch("app.models.facial_recognition.recognition.ArcFaceONNX") + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + + inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] + outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))] + ort_session.return_value.get_inputs.return_value = inputs + ort_session.return_value.get_outputs.return_value = outputs + + face_recognizer = FaceRecognizer( + "buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path, providers=["OpenVINOExecutionProvider"] + ) + face_recognizer.load() + + assert face_recognizer.batch_size == 1 update_dims.assert_not_called() onnx.load.assert_not_called() onnx.save.assert_not_called() @@ -771,11 +836,26 @@ class TestLoad: mock_model.model_format = ModelFormat.ONNX +def test_root_endpoint(deployed_app: TestClient) -> None: + response = deployed_app.get("http://localhost:3003") + + body = response.json() + assert response.status_code == 200 + assert body == {"message": "Immich ML"} + + +def test_ping_endpoint(deployed_app: TestClient) -> None: + response = deployed_app.get("http://localhost:3003/ping") + + assert response.status_code == 200 + assert response.text == "pong" + + @pytest.mark.skipif( not settings.test_full, reason="More time-consuming since it deploys the app and loads models.", ) -class TestEndpoints: +class TestPredictionEndpoints: def test_clip_image_endpoint( self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient ) -> None: diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 7edd525662..195e64ab35 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:94d6837f023c0fc0bb68782dd2a984ff7fe0e21ea7e533056c9b8ca060e31de2 as builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/gunicorn_conf.py b/machine-learning/gunicorn_conf.py new file mode 100644 index 0000000000..efec3a95aa --- /dev/null +++ b/machine-learning/gunicorn_conf.py @@ -0,0 +1,12 @@ +import os + +from gunicorn.arbiter import Arbiter +from gunicorn.workers.base import Worker + +device_ids = os.environ.get("MACHINE_LEARNING_DEVICE_IDS", "0").replace(" ", "").split(",") +env = os.environ + + +# Round-robin device assignment for each worker +def pre_fork(arbiter: Arbiter, _: Worker) -> None: + env["MACHINE_LEARNING_DEVICE_ID"] = device_ids[len(arbiter.WORKERS) % len(device_ids)] diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index d00b1349ec..a2da285750 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1,14 +1,14 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" description = "multi backend asyncio cache" optional = false python-versions = "*" files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {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] @@ -40,6 +40,17 @@ 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" @@ -64,33 +75,33 @@ trio = ["trio (>=0.23)"] [[package]] name = "black" -version = "24.4.2" +version = "24.10.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, - {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, - {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, - {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, - {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, - {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, - {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, - {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, - {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, - {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, - {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, - {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, - {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, - {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, - {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, - {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, - {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, - {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, - {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, - {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, - {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, - {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, + {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] @@ -104,7 +115,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -136,6 +147,10 @@ files = [ {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"}, @@ -148,8 +163,14 @@ files = [ {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"}, @@ -160,8 +181,24 @@ files = [ {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"}, @@ -171,6 +208,10 @@ files = [ {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"}, @@ -182,6 +223,10 @@ files = [ {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"}, @@ -194,6 +239,10 @@ files = [ {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"}, @@ -206,6 +255,10 @@ files = [ {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"}, @@ -224,63 +277,78 @@ files = [ [[package]] name = "cffi" -version = "1.16.0" +version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" files = [ - {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, - {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, - {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, - {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, - {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, - {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, - {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, - {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, - {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, - {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, - {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, - {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, - {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, - {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, - {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, - {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, - {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, - {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, - {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, - {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, - {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, - {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, - {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, - {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, - {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, - {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, + {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] @@ -680,23 +748,23 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.111.1" +version = "0.115.4" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.111.1-py3-none-any.whl", hash = "sha256:ac29948dcbf84cc78d68ed2c4df4e695ac265cf53c339e5794008476e9befbbb"}, - {file = "fastapi_slim-0.111.1.tar.gz", hash = "sha256:f799a60658f56c49fe3842eb534730fabe1168731c0b407b98a042c8d57be39d"}, + {file = "fastapi_slim-0.115.4-py3-none-any.whl", hash = "sha256:8947515618c21665590a1673a0bfe4c721db4267999c149d5301c3c0f7b3d9ce"}, + {file = "fastapi_slim-0.115.4.tar.gz", hash = "sha256:6d37987e4d1f6adefb8c7119c9b804e59c9b3f1a488be5425994d52308e2f958"}, ] [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.37.2,<0.38.0" +starlette = ">=0.40.0,<0.42.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email_validator (>=2.0.0)", "fastapi-cli (>=0.0.2)", "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 (>=0.0.2)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +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" @@ -878,73 +946,68 @@ tqdm = ["tqdm"] [[package]] name = "ftfy" -version = "6.2.0" +version = "6.3.0" description = "Fixes mojibake and other problems with Unicode, after the fact" optional = false -python-versions = ">=3.8,<4" +python-versions = ">=3.9" files = [ - {file = "ftfy-6.2.0-py3-none-any.whl", hash = "sha256:f94a2c34b76e07475720e3096f5ca80911d152406fbde66fdb45c4d0c9150026"}, - {file = "ftfy-6.2.0.tar.gz", hash = "sha256:5e42143c7025ef97944ca2619d6b61b0619fc6654f98771d39e862c1424c75c0"}, + {file = "ftfy-6.3.0-py3-none-any.whl", hash = "sha256:17aca296801f44142e3ff2c16f93fbf6a87609ebb3704a9a41dd5d4903396caf"}, + {file = "ftfy-6.3.0.tar.gz", hash = "sha256:1c7d6418e72b25a7760feb150acf574b86924dbb2e95b32c0b3abbd1ba3d7ad6"}, ] [package.dependencies] -wcwidth = ">=0.2.12,<0.3.0" +wcwidth = "*" [[package]] name = "gevent" -version = "23.9.1" +version = "24.10.3" description = "Coroutine-based network library" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "gevent-23.9.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:a3c5e9b1f766a7a64833334a18539a362fb563f6c4682f9634dea72cbe24f771"}, - {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b101086f109168b23fa3586fccd1133494bdb97f86920a24dc0b23984dc30b69"}, - {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36a549d632c14684bcbbd3014a6ce2666c5f2a500f34d58d32df6c9ea38b6535"}, - {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:272cffdf535978d59c38ed837916dfd2b5d193be1e9e5dcc60a5f4d5025dd98a"}, - {file = "gevent-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb8612787a7f4626aa881ff15ff25439561a429f5b303048f0fca8a1c781c39"}, - {file = "gevent-23.9.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d57737860bfc332b9b5aa438963986afe90f49645f6e053140cfa0fa1bdae1ae"}, - {file = "gevent-23.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5f3c781c84794926d853d6fb58554dc0dcc800ba25c41d42f6959c344b4db5a6"}, - {file = "gevent-23.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:dbb22a9bbd6a13e925815ce70b940d1578dbe5d4013f20d23e8a11eddf8d14a7"}, - {file = "gevent-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:707904027d7130ff3e59ea387dddceedb133cc742b00b3ffe696d567147a9c9e"}, - {file = "gevent-23.9.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:45792c45d60f6ce3d19651d7fde0bc13e01b56bb4db60d3f32ab7d9ec467374c"}, - {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e24c2af9638d6c989caffc691a039d7c7022a31c0363da367c0d32ceb4a0648"}, - {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e1ead6863e596a8cc2a03e26a7a0981f84b6b3e956101135ff6d02df4d9a6b07"}, - {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65883ac026731ac112184680d1f0f1e39fa6f4389fd1fc0bf46cc1388e2599f9"}, - {file = "gevent-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7af500da05363e66f122896012acb6e101a552682f2352b618e541c941a011"}, - {file = "gevent-23.9.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c3e5d2fa532e4d3450595244de8ccf51f5721a05088813c1abd93ad274fe15e7"}, - {file = "gevent-23.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c84d34256c243b0a53d4335ef0bc76c735873986d478c53073861a92566a8d71"}, - {file = "gevent-23.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ada07076b380918829250201df1d016bdafb3acf352f35e5693b59dceee8dd2e"}, - {file = "gevent-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:921dda1c0b84e3d3b1778efa362d61ed29e2b215b90f81d498eb4d8eafcd0b7a"}, - {file = "gevent-23.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ed7a048d3e526a5c1d55c44cb3bc06cfdc1947d06d45006cc4cf60dedc628904"}, - {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c1abc6f25f475adc33e5fc2dbcc26a732608ac5375d0d306228738a9ae14d3b"}, - {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4368f341a5f51611411ec3fc62426f52ac3d6d42eaee9ed0f9eebe715c80184e"}, - {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:52b4abf28e837f1865a9bdeef58ff6afd07d1d888b70b6804557e7908032e599"}, - {file = "gevent-23.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52e9f12cd1cda96603ce6b113d934f1aafb873e2c13182cf8e86d2c5c41982ea"}, - {file = "gevent-23.9.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:de350fde10efa87ea60d742901e1053eb2127ebd8b59a7d3b90597eb4e586599"}, - {file = "gevent-23.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fde6402c5432b835fbb7698f1c7f2809c8d6b2bd9d047ac1f5a7c1d5aa569303"}, - {file = "gevent-23.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dd6c32ab977ecf7c7b8c2611ed95fa4aaebd69b74bf08f4b4960ad516861517d"}, - {file = "gevent-23.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:455e5ee8103f722b503fa45dedb04f3ffdec978c1524647f8ba72b4f08490af1"}, - {file = "gevent-23.9.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7ccf0fd378257cb77d91c116e15c99e533374a8153632c48a3ecae7f7f4f09fe"}, - {file = "gevent-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d163d59f1be5a4c4efcdd13c2177baaf24aadf721fdf2e1af9ee54a998d160f5"}, - {file = "gevent-23.9.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7532c17bc6c1cbac265e751b95000961715adef35a25d2b0b1813aa7263fb397"}, - {file = "gevent-23.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:78eebaf5e73ff91d34df48f4e35581ab4c84e22dd5338ef32714264063c57507"}, - {file = "gevent-23.9.1-cp38-cp38-win32.whl", hash = "sha256:f632487c87866094546a74eefbca2c74c1d03638b715b6feb12e80120960185a"}, - {file = "gevent-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:62d121344f7465e3739989ad6b91f53a6ca9110518231553fe5846dbe1b4518f"}, - {file = "gevent-23.9.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:bf456bd6b992eb0e1e869e2fd0caf817f0253e55ca7977fd0e72d0336a8c1c6a"}, - {file = "gevent-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43daf68496c03a35287b8b617f9f91e0e7c0d042aebcc060cadc3f049aadd653"}, - {file = "gevent-23.9.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7c28e38dcde327c217fdafb9d5d17d3e772f636f35df15ffae2d933a5587addd"}, - {file = "gevent-23.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fae8d5b5b8fa2a8f63b39f5447168b02db10c888a3e387ed7af2bd1b8612e543"}, - {file = "gevent-23.9.1-cp39-cp39-win32.whl", hash = "sha256:2c7b5c9912378e5f5ccf180d1fdb1e83f42b71823483066eddbe10ef1a2fcaa2"}, - {file = "gevent-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:a2898b7048771917d85a1d548fd378e8a7b2ca963db8e17c6d90c76b495e0e2b"}, - {file = "gevent-23.9.1.tar.gz", hash = "sha256:72c002235390d46f94938a96920d8856d4ffd9ddf62a303a0d7c118894097e34"}, + {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.12.2", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} -greenlet = [ - {version = ">=3.0rc3", markers = "platform_python_implementation == \"CPython\" and python_version >= \"3.11\""}, - {version = ">=2.0.0", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""}, -] +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" = "*" @@ -952,8 +1015,8 @@ greenlet = [ 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.12.2)", "dnspython (>=1.16.0,<2.0)", "idna", "psutil (>=5.7.0)"] -test = ["cffi (>=1.12.2)", "coverage (>=5.0)", "dnspython (>=1.16.0,<2.0)", "idna", "objgraph", "psutil (>=5.7.0)", "requests", "setuptools"] +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" @@ -1040,69 +1103,84 @@ examples = ["oauth2"] [[package]] name = "greenlet" -version = "3.0.3" +version = "3.1.1" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.7" files = [ - {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, - {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, - {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, - {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, - {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, - {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, - {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, - {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, - {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, - {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, - {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, - {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, - {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, - {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, - {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, - {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, - {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, - {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, - {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, - {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, - {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, - {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, - {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, - {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, - {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, - {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, - {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, - {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, - {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, - {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, - {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, - {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, - {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, - {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, - {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, - {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, - {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, - {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, - {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, - {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, - {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, - {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, - {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, - {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, - {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, - {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, - {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, - {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, - {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, - {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, - {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, - {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, - {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, - {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, - {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, - {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, - {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, - {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, + {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] @@ -1111,13 +1189,13 @@ test = ["objgraph", "psutil"] [[package]] name = "gunicorn" -version = "22.0.0" +version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" files = [ - {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, - {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, ] [package.dependencies] @@ -1212,13 +1290,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] @@ -1233,16 +1311,17 @@ 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.24.0" +version = "0.26.2" 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.24.0-py3-none-any.whl", hash = "sha256:7ad92edefb93d8145c061f6df8d99df2ff85f8379ba5fac8a95aca0642afa5d7"}, - {file = "huggingface_hub-0.24.0.tar.gz", hash = "sha256:6c7092736b577d89d57b3cdfea026f1b0dc2234ae783fa0d59caf1bf7d52dfa7"}, + {file = "huggingface_hub-0.26.2-py3-none-any.whl", hash = "sha256:98c2a5a8e786c7b2cb6fdeb2740893cba4d53e312572ed3d8afafda65b128c46"}, + {file = "huggingface_hub-0.26.2.tar.gz", hash = "sha256:b100d853465d965733964d123939ba287da60a547087783ddff8a323f340332b"}, ] [package.dependencies] @@ -1255,16 +1334,16 @@ tqdm = ">=4.42.1" typing-extensions = ">=3.7.4.3" [package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "fastapi", "gradio", "jedi", "minijinja (>=1.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)"] +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", "jedi", "minijinja (>=1.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)"] +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", "minijinja (>=1.0)"] -quality = ["mypy (==1.5.1)", "ruff (>=0.5.0)"] +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", "jedi", "minijinja (>=1.0)", "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)"] +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)"] @@ -1530,13 +1609,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.29.1" +version = "2.32.0" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.29.1-py3-none-any.whl", hash = "sha256:8b15daab44cdf50eef1860a32bb30969423e3795247115e5a37446da3240c6d6"}, - {file = "locust-2.29.1.tar.gz", hash = "sha256:2e0628a59e2689a50cb4735a9a43709e30f2da7ed276c15d877c5325507f44b1"}, + {file = "locust-2.32.0-py3-none-any.whl", hash = "sha256:e004514332b8631ca91382d11d224baee4ced040c5f5c8b2233800ebcbc73c0e"}, + {file = "locust-2.32.0.tar.gz", hash = "sha256:d8f7f5d9d4e801b2e7b0ee3f31109333673da744ccedf85e7da0151f2d263dd9"}, ] [package.dependencies] @@ -1544,18 +1623,21 @@ ConfigArgParse = ">=1.5.5" flask = ">=2.0.0" Flask-Cors = ">=3.0.10" Flask-Login = ">=0.6.3" -gevent = ">=22.10.2" +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 = "platform_system == \"Windows\""} +pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pyzmq = ">=25.0.0" requests = [ - {version = ">=2.32.2", markers = "python_version > \"3.11\""}, - {version = ">=2.26.0", markers = "python_version <= \"3.11\""}, + {version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, + {version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, ] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} Werkzeug = ">=2.0.0" [[package]] @@ -1794,38 +1876,43 @@ files = [ [[package]] name = "mypy" -version = "1.11.0" +version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3824187c99b893f90c845bab405a585d1ced4ff55421fdf5c84cb7710995229"}, - {file = "mypy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96f8dbc2c85046c81bcddc246232d500ad729cb720da4e20fce3b542cab91287"}, - {file = "mypy-1.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a5d8d8dd8613a3e2be3eae829ee891b6b2de6302f24766ff06cb2875f5be9c6"}, - {file = "mypy-1.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72596a79bbfb195fd41405cffa18210af3811beb91ff946dbcb7368240eed6be"}, - {file = "mypy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:35ce88b8ed3a759634cb4eb646d002c4cef0a38f20565ee82b5023558eb90c00"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98790025861cb2c3db8c2f5ad10fc8c336ed2a55f4daf1b8b3f877826b6ff2eb"}, - {file = "mypy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:25bcfa75b9b5a5f8d67147a54ea97ed63a653995a82798221cca2a315c0238c1"}, - {file = "mypy-1.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bea2a0e71c2a375c9fa0ede3d98324214d67b3cbbfcbd55ac8f750f85a414e3"}, - {file = "mypy-1.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2b3d36baac48e40e3064d2901f2fbd2a2d6880ec6ce6358825c85031d7c0d4d"}, - {file = "mypy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8e2e43977f0e09f149ea69fd0556623919f816764e26d74da0c8a7b48f3e18a"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20"}, - {file = "mypy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba"}, - {file = "mypy-1.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd"}, - {file = "mypy-1.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d"}, - {file = "mypy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:940bfff7283c267ae6522ef926a7887305945f716a7704d3344d6d07f02df850"}, - {file = "mypy-1.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:14f9294528b5f5cf96c721f231c9f5b2733164e02c1c018ed1a0eff8a18005ac"}, - {file = "mypy-1.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7b54c27783991399046837df5c7c9d325d921394757d09dbcbf96aee4649fe9"}, - {file = "mypy-1.11.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:65f190a6349dec29c8d1a1cd4aa71284177aee5949e0502e6379b42873eddbe7"}, - {file = "mypy-1.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:dbe286303241fea8c2ea5466f6e0e6a046a135a7e7609167b07fd4e7baf151bf"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:104e9c1620c2675420abd1f6c44bab7dd33cc85aea751c985006e83dcd001095"}, - {file = "mypy-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f006e955718ecd8d159cee9932b64fba8f86ee6f7728ca3ac66c3a54b0062abe"}, - {file = "mypy-1.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:becc9111ca572b04e7e77131bc708480cc88a911adf3d0239f974c034b78085c"}, - {file = "mypy-1.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6801319fe76c3f3a3833f2b5af7bd2c17bb93c00026a2a1b924e6762f5b19e13"}, - {file = "mypy-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1a184c64521dc549324ec6ef7cbaa6b351912be9cb5edb803c2808a0d7e85ac"}, - {file = "mypy-1.11.0-py3-none-any.whl", hash = "sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace"}, - {file = "mypy-1.11.0.tar.gz", hash = "sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, ] [package.dependencies] @@ -1835,6 +1922,7 @@ typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -1962,69 +2050,69 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.18.1" +version = "1.19.2" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.18.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ef7683312393d4ba04252f1b287d964bd67d5e6048b94d2da3643986c74d80"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc706eb1df06ddf55776e15a30519fb15dda7697f987a2bbda4962845e3cec05"}, - {file = "onnxruntime-1.18.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7de69f5ced2a263531923fa68bbec52a56e793b802fcd81a03487b5e292bc3a"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win32.whl", hash = "sha256:221e5b16173926e6c7de2cd437764492aa12b6811f45abd37024e7cf2ae5d7e3"}, - {file = "onnxruntime-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:75211b619275199c861ee94d317243b8a0fcde6032e5a80e1aa9ded8ab4c6060"}, - {file = "onnxruntime-1.18.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:f26582882f2dc581b809cfa41a125ba71ad9e715738ec6402418df356969774a"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef36f3a8b768506d02be349ac303fd95d92813ba3ba70304d40c3cd5c25d6a4c"}, - {file = "onnxruntime-1.18.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:170e711393e0618efa8ed27b59b9de0ee2383bd2a1f93622a97006a5ad48e434"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win32.whl", hash = "sha256:9b6a33419b6949ea34e0dc009bc4470e550155b6da644571ecace4b198b0d88f"}, - {file = "onnxruntime-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:5c1380a9f1b7788da742c759b6a02ba771fe1ce620519b2b07309decbd1a2fe1"}, - {file = "onnxruntime-1.18.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:31bd57a55e3f983b598675dfc7e5d6f0877b70ec9864b3cc3c3e1923d0a01919"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9e03c4ba9f734500691a4d7d5b381cd71ee2f3ce80a1154ac8f7aed99d1ecaa"}, - {file = "onnxruntime-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:781aa9873640f5df24524f96f6070b8c550c66cb6af35710fd9f92a20b4bfbf6"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win32.whl", hash = "sha256:3a2d9ab6254ca62adbb448222e630dc6883210f718065063518c8f93a32432be"}, - {file = "onnxruntime-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:ad93c560b1c38c27c0275ffd15cd7f45b3ad3fc96653c09ce2931179982ff204"}, - {file = "onnxruntime-1.18.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:3b55dc9d3c67626388958a3eb7ad87eb7c70f75cb0f7ff4908d27b8b42f2475c"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f80dbcfb6763cc0177a31168b29b4bd7662545b99a19e211de8c734b657e0669"}, - {file = "onnxruntime-1.18.1-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ff2c61a16d6c8631796c54139bafea41ee7736077a0fc64ee8ae59432f5c58"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win32.whl", hash = "sha256:219855bd272fe0c667b850bf1a1a5a02499269a70d59c48e6f27f9c8bcb25d02"}, - {file = "onnxruntime-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:afdf16aa607eb9a2c60d5ca2d5abf9f448e90c345b6b94c3ed14f4fb7e6a2d07"}, - {file = "onnxruntime-1.18.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:128df253ade673e60cea0955ec9d0e89617443a6d9ce47c2d79eb3f72a3be3de"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9839491e77e5c5a175cab3621e184d5a88925ee297ff4c311b68897197f4cde9"}, - {file = "onnxruntime-1.18.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad3187c1faff3ac15f7f0e7373ef4788c582cafa655a80fdbb33eaec88976c66"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win32.whl", hash = "sha256:34657c78aa4e0b5145f9188b550ded3af626651b15017bf43d280d7e23dbf195"}, - {file = "onnxruntime-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:9c14fd97c3ddfa97da5feef595e2c73f14c2d0ec1d4ecbea99c8d96603c89589"}, + {file = "onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3a4ce906105d99ebbe817f536d50a91ed8a4d1592553f49b3c23c4be2560ae6"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5"}, + {file = "onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36511dc07c5c964b916697e42e366fa43c48cdb3d3503578d78cef30417cb84"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8"}, + {file = "onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:477b93df4db467e9cbf34051662a4b27c18e131fa1836e05974eae0d6e4cf29b"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857"}, + {file = "onnxruntime-1.19.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636bc1d4cc051d40bc52e1f9da87fbb9c57d9d47164695dfb1c41646ea51ea66"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bd8b875757ea941cbcfe01582970cc299893d1b65bd56731e326a8333f638a3"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2046fc9560f97947bbc1acbe4c6d48585ef0f12742744307d3364b131ac5778"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win32.whl", hash = "sha256:31c12840b1cde4ac1f7d27d540c44e13e34f2345cf3642762d2a3333621abb6a"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:016229660adea180e9a32ce218b95f8f84860a200f0f13b50070d7d90e92956c"}, + {file = "onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fae4b4de45894b9ce7ae418c5484cbf0341db6813effec01bb2216091c52f7fb"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.21.6,<2.0" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" [[package]] name = "onnxruntime-gpu" -version = "1.18.1" +version = "1.19.2" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_gpu-1.18.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e9a52f5d43a84fe29e135da6bf10daa18836c81bed9060a5924efd6afc0d259"}, - {file = "onnxruntime_gpu-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:e7c1c665e8a11a5cf15369948b04288dc0a6812ad2e6beaff93a3d157c864d9a"}, - {file = "onnxruntime_gpu-1.18.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1334f802cb1e4e2eb6ceebc4ef71ba44f3ef444d34216baafb940368a7a5d2f5"}, - {file = "onnxruntime_gpu-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:0ffcc711e89b80c935d5172544f8a605b11525fc1e6f0e78ee79e2c28956e2d9"}, - {file = "onnxruntime_gpu-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbb1a6c986b2392eebaebc43e198a1614e3f7d2c191725002dbfa0dceb24454b"}, - {file = "onnxruntime_gpu-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:bee352929e6eec2ff4e11e323a025ed8bd5eac24795005bc502ac740971fa7bd"}, - {file = "onnxruntime_gpu-1.18.1-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:76d307a849a863d0457869febe4b2fd2fc07c7f26385c7339d17066312fa6be0"}, - {file = "onnxruntime_gpu-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:b7498d6c64a03558308ce6d7d14dab306ea90d1204b563890c4d2d26c1b520f0"}, - {file = "onnxruntime_gpu-1.18.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1d8113cb4b8a51b195fae91cfeb6849728462a4b46aaf51b6764c44e54f81f"}, - {file = "onnxruntime_gpu-1.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:fc1d2544a39f5db64c5b8a0c24d0b934d7d64682e6d70763eb2cc726b1fd6c3f"}, + {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,<2.0" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2074,70 +2162,77 @@ files = [ [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\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] [[package]] name = "orjson" -version = "3.10.6" +version = "3.10.10" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = false python-versions = ">=3.8" files = [ - {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, - {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, - {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, - {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, - {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, - {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, - {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, - {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, - {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, - {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, - {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, - {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, - {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, - {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, - {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, - {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, - {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, - {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, - {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, - {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, - {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, - {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, - {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, - {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, - {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, - {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, + {file = "orjson-3.10.10-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b788a579b113acf1c57e0a68e558be71d5d09aa67f62ca1f68e01117e550a998"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:804b18e2b88022c8905bb79bd2cbe59c0cd014b9328f43da8d3b28441995cda4"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9972572a1d042ec9ee421b6da69f7cc823da5962237563fa548ab17f152f0b9b"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc6993ab1c2ae7dd0711161e303f1db69062955ac2668181bfdf2dd410e65258"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d78e4cacced5781b01d9bc0f0cd8b70b906a0e109825cb41c1b03f9c41e4ce86"}, + {file = "orjson-3.10.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6eb2598df518281ba0cbc30d24c5b06124ccf7e19169e883c14e0831217a0bc"}, + {file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23776265c5215ec532de6238a52707048401a568f0fa0d938008e92a147fe2c7"}, + {file = "orjson-3.10.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cc2a654c08755cef90b468ff17c102e2def0edd62898b2486767204a7f5cc9c"}, + {file = "orjson-3.10.10-cp310-none-win32.whl", hash = "sha256:081b3fc6a86d72efeb67c13d0ea7c030017bd95f9868b1e329a376edc456153b"}, + {file = "orjson-3.10.10-cp310-none-win_amd64.whl", hash = "sha256:ff38c5fb749347768a603be1fb8a31856458af839f31f064c5aa74aca5be9efe"}, + {file = "orjson-3.10.10-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:879e99486c0fbb256266c7c6a67ff84f46035e4f8749ac6317cc83dacd7f993a"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:019481fa9ea5ff13b5d5d95e6fd5ab25ded0810c80b150c2c7b1cc8660b662a7"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0dd57eff09894938b4c86d4b871a479260f9e156fa7f12f8cad4b39ea8028bb5"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbde6d70cd95ab4d11ea8ac5e738e30764e510fc54d777336eec09bb93b8576c"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2625cb37b8fb42e2147404e5ff7ef08712099197a9cd38895006d7053e69d6"}, + {file = "orjson-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf3c20c6a7db69df58672a0d5815647ecf78c8e62a4d9bd284e8621c1fe5ccb"}, + {file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:75c38f5647e02d423807d252ce4528bf6a95bd776af999cb1fb48867ed01d1f6"}, + {file = "orjson-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23458d31fa50ec18e0ec4b0b4343730928296b11111df5f547c75913714116b2"}, + {file = "orjson-3.10.10-cp311-none-win32.whl", hash = "sha256:2787cd9dedc591c989f3facd7e3e86508eafdc9536a26ec277699c0aa63c685b"}, + {file = "orjson-3.10.10-cp311-none-win_amd64.whl", hash = "sha256:6514449d2c202a75183f807bc755167713297c69f1db57a89a1ef4a0170ee269"}, + {file = "orjson-3.10.10-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8564f48f3620861f5ef1e080ce7cd122ee89d7d6dacf25fcae675ff63b4d6e05"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5bf161a32b479034098c5b81f2608f09167ad2fa1c06abd4e527ea6bf4837a9"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b65c93617bcafa7f04b74ae8bc2cc214bd5cb45168a953256ff83015c6747d"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8e28406f97fc2ea0c6150f4c1b6e8261453318930b334abc419214c82314f85"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4d0d9fe174cc7a5bdce2e6c378bcdb4c49b2bf522a8f996aa586020e1b96cee"}, + {file = "orjson-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3be81c42f1242cbed03cbb3973501fcaa2675a0af638f8be494eaf37143d999"}, + {file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65f9886d3bae65be026219c0a5f32dbbe91a9e6272f56d092ab22561ad0ea33b"}, + {file = "orjson-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:730ed5350147db7beb23ddaf072f490329e90a1d059711d364b49fe352ec987b"}, + {file = "orjson-3.10.10-cp312-none-win32.whl", hash = "sha256:a8f4bf5f1c85bea2170800020d53a8877812892697f9c2de73d576c9307a8a5f"}, + {file = "orjson-3.10.10-cp312-none-win_amd64.whl", hash = "sha256:384cd13579a1b4cd689d218e329f459eb9ddc504fa48c5a83ef4889db7fd7a4f"}, + {file = "orjson-3.10.10-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44bffae68c291f94ff5a9b4149fe9d1bdd4cd0ff0fb575bcea8351d48db629a1"}, + {file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e27b4c6437315df3024f0835887127dac2a0a3ff643500ec27088d2588fa5ae1"}, + {file = "orjson-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca84df16d6b49325a4084fd8b2fe2229cb415e15c46c529f868c3387bb1339d"}, + {file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c14ce70e8f39bd71f9f80423801b5d10bf93d1dceffdecd04df0f64d2c69bc01"}, + {file = "orjson-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24ac62336da9bda1bd93c0491eff0613003b48d3cb5d01470842e7b52a40d5b4"}, + {file = "orjson-3.10.10-cp313-none-win32.whl", hash = "sha256:eb0a42831372ec2b05acc9ee45af77bcaccbd91257345f93780a8e654efc75db"}, + {file = "orjson-3.10.10-cp313-none-win_amd64.whl", hash = "sha256:f0c4f37f8bf3f1075c6cc8dd8a9f843689a4b618628f8812d0a71e6968b95ffd"}, + {file = "orjson-3.10.10-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:829700cc18503efc0cf502d630f612884258020d98a317679cd2054af0259568"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0ceb5e0e8c4f010ac787d29ae6299846935044686509e2f0f06ed441c1ca949"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c25908eb86968613216f3db4d3003f1c45d78eb9046b71056ca327ff92bdbd4"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:218cb0bc03340144b6328a9ff78f0932e642199ac184dd74b01ad691f42f93ff"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2277ec2cea3775640dc81ab5195bb5b2ada2fe0ea6eee4677474edc75ea6785"}, + {file = "orjson-3.10.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:848ea3b55ab5ccc9d7bbd420d69432628b691fba3ca8ae3148c35156cbd282aa"}, + {file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e3e67b537ac0c835b25b5f7d40d83816abd2d3f4c0b0866ee981a045287a54f3"}, + {file = "orjson-3.10.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7948cfb909353fce2135dcdbe4521a5e7e1159484e0bb024c1722f272488f2b8"}, + {file = "orjson-3.10.10-cp38-none-win32.whl", hash = "sha256:78bee66a988f1a333dc0b6257503d63553b1957889c17b2c4ed72385cd1b96ae"}, + {file = "orjson-3.10.10-cp38-none-win_amd64.whl", hash = "sha256:f1d647ca8d62afeb774340a343c7fc023efacfd3a39f70c798991063f0c681dd"}, + {file = "orjson-3.10.10-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5a059afddbaa6dd733b5a2d76a90dbc8af790b993b1b5cb97a1176ca713b5df8"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f9b5c59f7e2a1a410f971c5ebc68f1995822837cd10905ee255f96074537ee6"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d5ef198bafdef4aa9d49a4165ba53ffdc0a9e1c7b6f76178572ab33118afea25"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf29ce0bb5d3320824ec3d1508652421000ba466abd63bdd52c64bcce9eb1fa"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dddd5516bcc93e723d029c1633ae79c4417477b4f57dad9bfeeb6bc0315e654a"}, + {file = "orjson-3.10.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12f2003695b10817f0fa8b8fca982ed7f5761dcb0d93cff4f2f9f6709903fd7"}, + {file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:672f9874a8a8fb9bb1b771331d31ba27f57702c8106cdbadad8bda5d10bc1019"}, + {file = "orjson-3.10.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dcbb0ca5fafb2b378b2c74419480ab2486326974826bbf6588f4dc62137570a"}, + {file = "orjson-3.10.10-cp39-none-win32.whl", hash = "sha256:d9bbd3a4b92256875cb058c3381b782649b9a3c68a4aa9a2fff020c2f9cfc1be"}, + {file = "orjson-3.10.10-cp39-none-win_amd64.whl", hash = "sha256:766f21487a53aee8524b97ca9582d5c6541b03ab6210fbaf10142ae2f3ced2aa"}, + {file = "orjson-3.10.10.tar.gz", hash = "sha256:37949383c4df7b4337ce82ee35b6d7471e55195efa7dcb45ab8226ceadb0fe3b"}, ] [[package]] @@ -2367,62 +2462,147 @@ files = [ [[package]] name = "pydantic" -version = "1.10.17" -description = "Data validation and settings management using python type hints" +version = "2.9.2" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.17-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fa51175313cc30097660b10eec8ca55ed08bfa07acbfe02f7a42f6c242e9a4b"}, - {file = "pydantic-1.10.17-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7e8988bb16988890c985bd2093df9dd731bfb9d5e0860db054c23034fab8f7a"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:371dcf1831f87c9e217e2b6a0c66842879a14873114ebb9d0861ab22e3b5bb1e"}, - {file = "pydantic-1.10.17-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4866a1579c0c3ca2c40575398a24d805d4db6cb353ee74df75ddeee3c657f9a7"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:543da3c6914795b37785703ffc74ba4d660418620cc273490d42c53949eeeca6"}, - {file = "pydantic-1.10.17-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7623b59876f49e61c2e283551cc3647616d2fbdc0b4d36d3d638aae8547ea681"}, - {file = "pydantic-1.10.17-cp310-cp310-win_amd64.whl", hash = "sha256:409b2b36d7d7d19cd8310b97a4ce6b1755ef8bd45b9a2ec5ec2b124db0a0d8f3"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fa43f362b46741df8f201bf3e7dff3569fa92069bcc7b4a740dea3602e27ab7a"}, - {file = "pydantic-1.10.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a72d2a5ff86a3075ed81ca031eac86923d44bc5d42e719d585a8eb547bf0c9b"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4ad32aed3bf5eea5ca5decc3d1bbc3d0ec5d4fbcd72a03cdad849458decbc63"}, - {file = "pydantic-1.10.17-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb4e741782e236ee7dc1fb11ad94dc56aabaf02d21df0e79e0c21fe07c95741"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d2f89a719411cb234105735a520b7c077158a81e0fe1cb05a79c01fc5eb59d3c"}, - {file = "pydantic-1.10.17-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db3b48d9283d80a314f7a682f7acae8422386de659fffaba454b77a083c3937d"}, - {file = "pydantic-1.10.17-cp311-cp311-win_amd64.whl", hash = "sha256:9c803a5113cfab7bbb912f75faa4fc1e4acff43e452c82560349fff64f852e1b"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:820ae12a390c9cbb26bb44913c87fa2ff431a029a785642c1ff11fed0a095fcb"}, - {file = "pydantic-1.10.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1e51d1af306641b7d1574d6d3307eaa10a4991542ca324f0feb134fee259815"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e53fb834aae96e7b0dadd6e92c66e7dd9cdf08965340ed04c16813102a47fab"}, - {file = "pydantic-1.10.17-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e2495309b1266e81d259a570dd199916ff34f7f51f1b549a0d37a6d9b17b4dc"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:098ad8de840c92ea586bf8efd9e2e90c6339d33ab5c1cfbb85be66e4ecf8213f"}, - {file = "pydantic-1.10.17-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:525bbef620dac93c430d5d6bdbc91bdb5521698d434adf4434a7ef6ffd5c4b7f"}, - {file = "pydantic-1.10.17-cp312-cp312-win_amd64.whl", hash = "sha256:6654028d1144df451e1da69a670083c27117d493f16cf83da81e1e50edce72ad"}, - {file = "pydantic-1.10.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c87cedb4680d1614f1d59d13fea353faf3afd41ba5c906a266f3f2e8c245d655"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11289fa895bcbc8f18704efa1d8020bb9a86314da435348f59745473eb042e6b"}, - {file = "pydantic-1.10.17-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94833612d6fd18b57c359a127cbfd932d9150c1b72fea7c86ab58c2a77edd7c7"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d4ecb515fa7cb0e46e163ecd9d52f9147ba57bc3633dca0e586cdb7a232db9e3"}, - {file = "pydantic-1.10.17-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7017971ffa7fd7808146880aa41b266e06c1e6e12261768a28b8b41ba55c8076"}, - {file = "pydantic-1.10.17-cp37-cp37m-win_amd64.whl", hash = "sha256:e840e6b2026920fc3f250ea8ebfdedf6ea7a25b77bf04c6576178e681942ae0f"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bfbb18b616abc4df70591b8c1ff1b3eabd234ddcddb86b7cac82657ab9017e33"}, - {file = "pydantic-1.10.17-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebb249096d873593e014535ab07145498957091aa6ae92759a32d40cb9998e2e"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8c209af63ccd7b22fba94b9024e8b7fd07feffee0001efae50dd99316b27768"}, - {file = "pydantic-1.10.17-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b40c9e13a0b61583e5599e7950490c700297b4a375b55b2b592774332798b7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c31d281c7485223caf6474fc2b7cf21456289dbaa31401844069b77160cab9c7"}, - {file = "pydantic-1.10.17-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae5184e99a060a5c80010a2d53c99aee76a3b0ad683d493e5f0620b5d86eeb75"}, - {file = "pydantic-1.10.17-cp38-cp38-win_amd64.whl", hash = "sha256:ad1e33dc6b9787a6f0f3fd132859aa75626528b49cc1f9e429cdacb2608ad5f0"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e17c0ee7192e54a10943f245dc79e36d9fe282418ea05b886e1c666063a7b54"}, - {file = "pydantic-1.10.17-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cafb9c938f61d1b182dfc7d44a7021326547b7b9cf695db5b68ec7b590214773"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95ef534e3c22e5abbdbdd6f66b6ea9dac3ca3e34c5c632894f8625d13d084cbe"}, - {file = "pydantic-1.10.17-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d96b8799ae3d782df7ec9615cb59fc32c32e1ed6afa1b231b0595f6516e8ab"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ab2f976336808fd5d539fdc26eb51f9aafc1f4b638e212ef6b6f05e753c8011d"}, - {file = "pydantic-1.10.17-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8ad363330557beac73159acfbeed220d5f1bfcd6b930302a987a375e02f74fd"}, - {file = "pydantic-1.10.17-cp39-cp39-win_amd64.whl", hash = "sha256:48db882e48575ce4b39659558b2f9f37c25b8d348e37a2b4e32971dd5a7d6227"}, - {file = "pydantic-1.10.17-py3-none-any.whl", hash = "sha256:e41b5b973e5c64f674b3b4720286ded184dcc26a691dd55f34391c62c6934688"}, - {file = "pydantic-1.10.17.tar.gz", hash = "sha256:f434160fb14b353caf634149baaf847206406471ba70e64657c1e8330277a991"}, + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, +] [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.6.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.6.0-py3-none-any.whl", hash = "sha256:4a819166f119b74d7f8c765196b165f95cc7487ce58ea27dec8a5a26be0970e0"}, + {file = "pydantic_settings-2.6.0.tar.gz", hash = "sha256:44a1804abffac9e6a30372bb45f6cafab945ef5af25e66b1c634c01dd39e0188"}, +] + +[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" @@ -2466,13 +2646,13 @@ files = [ [[package]] name = "pytest" -version = "8.2.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, - {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -2480,7 +2660,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.5,<2.0" +pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] @@ -2488,17 +2668,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.8" +version = "0.24.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, - {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, ] [package.dependencies] -pytest = ">=7.0.0,<9" +pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -2506,13 +2686,13 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "4.1.0" +version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, ] [package.dependencies] @@ -2520,7 +2700,7 @@ coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-mock" @@ -2569,18 +2749,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.12" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"}, + {file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2809,47 +2986,48 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.9.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283"}, + {file = "rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e"}, ] [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 = "ruff" -version = "0.5.4" +version = "0.7.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"}, - {file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"}, - {file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"}, - {file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"}, - {file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"}, - {file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"}, - {file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"}, - {file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"}, - {file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"}, + {file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"}, + {file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"}, + {file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"}, + {file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"}, + {file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"}, + {file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"}, + {file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"}, ] [[package]] @@ -2991,19 +3169,18 @@ test = ["asv", "gmpy2", "mpmath", "pooch", "pytest", "pytest-cov", "pytest-timeo [[package]] name = "setuptools" -version = "68.2.2" +version = "70.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, - {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +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" @@ -3029,13 +3206,13 @@ files = [ [[package]] name = "starlette" -version = "0.37.2" +version = "0.41.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" files = [ - {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, - {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, + {file = "starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d"}, + {file = "starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62"}, ] [package.dependencies] @@ -3088,111 +3265,111 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib" [[package]] name = "tokenizers" -version = "0.19.1" +version = "0.20.1" description = "" optional = false python-versions = ">=3.7" files = [ - {file = "tokenizers-0.19.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:952078130b3d101e05ecfc7fc3640282d74ed26bcf691400f872563fca15ac97"}, - {file = "tokenizers-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82c8b8063de6c0468f08e82c4e198763e7b97aabfe573fd4cf7b33930ca4df77"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f03727225feaf340ceeb7e00604825addef622d551cbd46b7b775ac834c1e1c4"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:453e4422efdfc9c6b6bf2eae00d5e323f263fff62b29a8c9cd526c5003f3f642"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02e81bf089ebf0e7f4df34fa0207519f07e66d8491d963618252f2e0729e0b46"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b07c538ba956843833fee1190cf769c60dc62e1cf934ed50d77d5502194d63b1"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28cab1582e0eec38b1f38c1c1fb2e56bce5dc180acb1724574fc5f47da2a4fe"}, - {file = "tokenizers-0.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b01afb7193d47439f091cd8f070a1ced347ad0f9144952a30a41836902fe09e"}, - {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7fb297edec6c6841ab2e4e8f357209519188e4a59b557ea4fafcf4691d1b4c98"}, - {file = "tokenizers-0.19.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2e8a3dd055e515df7054378dc9d6fa8c8c34e1f32777fb9a01fea81496b3f9d3"}, - {file = "tokenizers-0.19.1-cp310-none-win32.whl", hash = "sha256:7ff898780a155ea053f5d934925f3902be2ed1f4d916461e1a93019cc7250837"}, - {file = "tokenizers-0.19.1-cp310-none-win_amd64.whl", hash = "sha256:bea6f9947e9419c2fda21ae6c32871e3d398cba549b93f4a65a2d369662d9403"}, - {file = "tokenizers-0.19.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5c88d1481f1882c2e53e6bb06491e474e420d9ac7bdff172610c4f9ad3898059"}, - {file = "tokenizers-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddf672ed719b4ed82b51499100f5417d7d9f6fb05a65e232249268f35de5ed14"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:dadc509cc8a9fe460bd274c0e16ac4184d0958117cf026e0ea8b32b438171594"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfedf31824ca4915b511b03441784ff640378191918264268e6923da48104acc"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac11016d0a04aa6487b1513a3a36e7bee7eec0e5d30057c9c0408067345c48d2"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76951121890fea8330d3a0df9a954b3f2a37e3ec20e5b0530e9a0044ca2e11fe"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b342d2ce8fc8d00f376af068e3274e2e8649562e3bc6ae4a67784ded6b99428d"}, - {file = "tokenizers-0.19.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d16ff18907f4909dca9b076b9c2d899114dd6abceeb074eca0c93e2353f943aa"}, - {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:706a37cc5332f85f26efbe2bdc9ef8a9b372b77e4645331a405073e4b3a8c1c6"}, - {file = "tokenizers-0.19.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16baac68651701364b0289979ecec728546133e8e8fe38f66fe48ad07996b88b"}, - {file = "tokenizers-0.19.1-cp311-none-win32.whl", hash = "sha256:9ed240c56b4403e22b9584ee37d87b8bfa14865134e3e1c3fb4b2c42fafd3256"}, - {file = "tokenizers-0.19.1-cp311-none-win_amd64.whl", hash = "sha256:ad57d59341710b94a7d9dbea13f5c1e7d76fd8d9bcd944a7a6ab0b0da6e0cc66"}, - {file = "tokenizers-0.19.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:621d670e1b1c281a1c9698ed89451395d318802ff88d1fc1accff0867a06f153"}, - {file = "tokenizers-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d924204a3dbe50b75630bd16f821ebda6a5f729928df30f582fb5aade90c818a"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f3fefdc0446b1a1e6d81cd4c07088ac015665d2e812f6dbba4a06267d1a2c95"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9620b78e0b2d52ef07b0d428323fb34e8ea1219c5eac98c2596311f20f1f9266"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04ce49e82d100594715ac1b2ce87d1a36e61891a91de774755f743babcd0dd52"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5c2ff13d157afe413bf7e25789879dd463e5a4abfb529a2d8f8473d8042e28f"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3174c76efd9d08f836bfccaca7cfec3f4d1c0a4cf3acbc7236ad577cc423c840"}, - {file = "tokenizers-0.19.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c9d5b6c0e7a1e979bec10ff960fae925e947aab95619a6fdb4c1d8ff3708ce3"}, - {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a179856d1caee06577220ebcfa332af046d576fb73454b8f4d4b0ba8324423ea"}, - {file = "tokenizers-0.19.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:952b80dac1a6492170f8c2429bd11fcaa14377e097d12a1dbe0ef2fb2241e16c"}, - {file = "tokenizers-0.19.1-cp312-none-win32.whl", hash = "sha256:01d62812454c188306755c94755465505836fd616f75067abcae529c35edeb57"}, - {file = "tokenizers-0.19.1-cp312-none-win_amd64.whl", hash = "sha256:b70bfbe3a82d3e3fb2a5e9b22a39f8d1740c96c68b6ace0086b39074f08ab89a"}, - {file = "tokenizers-0.19.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:bb9dfe7dae85bc6119d705a76dc068c062b8b575abe3595e3c6276480e67e3f1"}, - {file = "tokenizers-0.19.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:1f0360cbea28ea99944ac089c00de7b2e3e1c58f479fb8613b6d8d511ce98267"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:71e3ec71f0e78780851fef28c2a9babe20270404c921b756d7c532d280349214"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b82931fa619dbad979c0ee8e54dd5278acc418209cc897e42fac041f5366d626"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ff5b90eabdcdaa19af697885f70fe0b714ce16709cf43d4952f1f85299e73a"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e742d76ad84acbdb1a8e4694f915fe59ff6edc381c97d6dfdd054954e3478ad4"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8c5d59d7b59885eab559d5bc082b2985555a54cda04dda4c65528d90ad252ad"}, - {file = "tokenizers-0.19.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2da5c32ed869bebd990c9420df49813709e953674c0722ff471a116d97b22d"}, - {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:638e43936cc8b2cbb9f9d8dde0fe5e7e30766a3318d2342999ae27f68fdc9bd6"}, - {file = "tokenizers-0.19.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:78e769eb3b2c79687d9cb0f89ef77223e8e279b75c0a968e637ca7043a84463f"}, - {file = "tokenizers-0.19.1-cp37-none-win32.whl", hash = "sha256:72791f9bb1ca78e3ae525d4782e85272c63faaef9940d92142aa3eb79f3407a3"}, - {file = "tokenizers-0.19.1-cp37-none-win_amd64.whl", hash = "sha256:f3bbb7a0c5fcb692950b041ae11067ac54826204318922da754f908d95619fbc"}, - {file = "tokenizers-0.19.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:07f9295349bbbcedae8cefdbcfa7f686aa420be8aca5d4f7d1ae6016c128c0c5"}, - {file = "tokenizers-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10a707cc6c4b6b183ec5dbfc5c34f3064e18cf62b4a938cb41699e33a99e03c1"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6309271f57b397aa0aff0cbbe632ca9d70430839ca3178bf0f06f825924eca22"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ad23d37d68cf00d54af184586d79b84075ada495e7c5c0f601f051b162112dc"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:427c4f0f3df9109314d4f75b8d1f65d9477033e67ffaec4bca53293d3aca286d"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e83a31c9cf181a0a3ef0abad2b5f6b43399faf5da7e696196ddd110d332519ee"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c27b99889bd58b7e301468c0838c5ed75e60c66df0d4db80c08f43462f82e0d3"}, - {file = "tokenizers-0.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bac0b0eb952412b0b196ca7a40e7dce4ed6f6926489313414010f2e6b9ec2adf"}, - {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8a6298bde623725ca31c9035a04bf2ef63208d266acd2bed8c2cb7d2b7d53ce6"}, - {file = "tokenizers-0.19.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:08a44864e42fa6d7d76d7be4bec62c9982f6f6248b4aa42f7302aa01e0abfd26"}, - {file = "tokenizers-0.19.1-cp38-none-win32.whl", hash = "sha256:1de5bc8652252d9357a666e609cb1453d4f8e160eb1fb2830ee369dd658e8975"}, - {file = "tokenizers-0.19.1-cp38-none-win_amd64.whl", hash = "sha256:0bcce02bf1ad9882345b34d5bd25ed4949a480cf0e656bbd468f4d8986f7a3f1"}, - {file = "tokenizers-0.19.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0b9394bd204842a2a1fd37fe29935353742be4a3460b6ccbaefa93f58a8df43d"}, - {file = "tokenizers-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4692ab92f91b87769d950ca14dbb61f8a9ef36a62f94bad6c82cc84a51f76f6a"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6258c2ef6f06259f70a682491c78561d492e885adeaf9f64f5389f78aa49a051"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85cf76561fbd01e0d9ea2d1cbe711a65400092bc52b5242b16cfd22e51f0c58"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:670b802d4d82bbbb832ddb0d41df7015b3e549714c0e77f9bed3e74d42400fbe"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85aa3ab4b03d5e99fdd31660872249df5e855334b6c333e0bc13032ff4469c4a"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbf001afbbed111a79ca47d75941e9e5361297a87d186cbfc11ed45e30b5daba"}, - {file = "tokenizers-0.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c89aa46c269e4e70c4d4f9d6bc644fcc39bb409cb2a81227923404dd6f5227"}, - {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:39c1ec76ea1027438fafe16ecb0fb84795e62e9d643444c1090179e63808c69d"}, - {file = "tokenizers-0.19.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c2a0d47a89b48d7daa241e004e71fb5a50533718897a4cd6235cb846d511a478"}, - {file = "tokenizers-0.19.1-cp39-none-win32.whl", hash = "sha256:61b7fe8886f2e104d4caf9218b157b106207e0f2a4905c9c7ac98890688aabeb"}, - {file = "tokenizers-0.19.1-cp39-none-win_amd64.whl", hash = "sha256:f97660f6c43efd3e0bfd3f2e3e5615bf215680bad6ee3d469df6454b8c6e8256"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b11853f17b54c2fe47742c56d8a33bf49ce31caf531e87ac0d7d13d327c9334"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d26194ef6c13302f446d39972aaa36a1dda6450bc8949f5eb4c27f51191375bd"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e8d1ed93beda54bbd6131a2cb363a576eac746d5c26ba5b7556bc6f964425594"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca407133536f19bdec44b3da117ef0d12e43f6d4b56ac4c765f37eca501c7bda"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce05fde79d2bc2e46ac08aacbc142bead21614d937aac950be88dc79f9db9022"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:35583cd46d16f07c054efd18b5d46af4a2f070a2dd0a47914e66f3ff5efb2b1e"}, - {file = "tokenizers-0.19.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:43350270bfc16b06ad3f6f07eab21f089adb835544417afda0f83256a8bf8b75"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b4399b59d1af5645bcee2072a463318114c39b8547437a7c2d6a186a1b5a0e2d"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6852c5b2a853b8b0ddc5993cd4f33bfffdca4fcc5d52f89dd4b8eada99379285"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd266ae85c3d39df2f7e7d0e07f6c41a55e9a3123bb11f854412952deacd828"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecb2651956eea2aa0a2d099434134b1b68f1c31f9a5084d6d53f08ed43d45ff2"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b279ab506ec4445166ac476fb4d3cc383accde1ea152998509a94d82547c8e2a"}, - {file = "tokenizers-0.19.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:89183e55fb86e61d848ff83753f64cded119f5d6e1f553d14ffee3700d0a4a49"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2edbc75744235eea94d595a8b70fe279dd42f3296f76d5a86dde1d46e35f574"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:0e64bfde9a723274e9a71630c3e9494ed7b4c0f76a1faacf7fe294cd26f7ae7c"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0b5ca92bfa717759c052e345770792d02d1f43b06f9e790ca0a1db62838816f3"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f8a20266e695ec9d7a946a019c1d5ca4eddb6613d4f466888eee04f16eedb85"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63c38f45d8f2a2ec0f3a20073cccb335b9f99f73b3c69483cd52ebc75369d8a1"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dd26e3afe8a7b61422df3176e06664503d3f5973b94f45d5c45987e1cb711876"}, - {file = "tokenizers-0.19.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:eddd5783a4a6309ce23432353cdb36220e25cbb779bfa9122320666508b44b88"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:56ae39d4036b753994476a1b935584071093b55c7a72e3b8288e68c313ca26e7"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f9939ca7e58c2758c01b40324a59c034ce0cebad18e0d4563a9b1beab3018243"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c330c0eb815d212893c67a032e9dc1b38a803eccb32f3e8172c19cc69fbb439"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec11802450a2487cdf0e634b750a04cbdc1c4d066b97d94ce7dd2cb51ebb325b"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b718f316b596f36e1dae097a7d5b91fc5b85e90bf08b01ff139bd8953b25af"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ed69af290c2b65169f0ba9034d1dc39a5db9459b32f1dd8b5f3f32a3fcf06eab"}, - {file = "tokenizers-0.19.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f8a9c828277133af13f3859d1b6bf1c3cb6e9e1637df0e45312e6b7c2e622b1f"}, - {file = "tokenizers-0.19.1.tar.gz", hash = "sha256:ee59e6680ed0fdbe6b724cf38bd70400a0c1dd623b07ac729087270caeac88e3"}, + {file = "tokenizers-0.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:439261da7c0a5c88bda97acb284d49fbdaf67e9d3b623c0bfd107512d22787a9"}, + {file = "tokenizers-0.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03dae629d99068b1ea5416d50de0fea13008f04129cc79af77a2a6392792d93c"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b61f561f329ffe4b28367798b89d60c4abf3f815d37413b6352bc6412a359867"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec870fce1ee5248a10be69f7a8408a234d6f2109f8ea827b4f7ecdbf08c9fd15"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d388d1ea8b7447da784e32e3b86a75cce55887e3b22b31c19d0b186b1c677800"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:299c85c1d21135bc01542237979bf25c32efa0d66595dd0069ae259b97fb2dbe"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96f6c14c9752bb82145636b614d5a78e9cde95edfbe0a85dad0dd5ddd6ec95c"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9e95ad49c932b80abfbfeaf63b155761e695ad9f8a58c52a47d962d76e310f"}, + {file = "tokenizers-0.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f22dee205329a636148c325921c73cf3e412e87d31f4d9c3153b302a0200057b"}, + {file = "tokenizers-0.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2ffd9a8895575ac636d44500c66dffaef133823b6b25067604fa73bbc5ec09d"}, + {file = "tokenizers-0.20.1-cp310-none-win32.whl", hash = "sha256:2847843c53f445e0f19ea842a4e48b89dd0db4e62ba6e1e47a2749d6ec11f50d"}, + {file = "tokenizers-0.20.1-cp310-none-win_amd64.whl", hash = "sha256:f9aa93eacd865f2798b9e62f7ce4533cfff4f5fbd50c02926a78e81c74e432cd"}, + {file = "tokenizers-0.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4a717dcb08f2dabbf27ae4b6b20cbbb2ad7ed78ce05a829fae100ff4b3c7ff15"}, + {file = "tokenizers-0.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f84dad1ff1863c648d80628b1b55353d16303431283e4efbb6ab1af56a75832"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:929c8f3afa16a5130a81ab5079c589226273ec618949cce79b46d96e59a84f61"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10766473954397e2d370f215ebed1cc46dcf6fd3906a2a116aa1d6219bfedc3"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9300fac73ddc7e4b0330acbdda4efaabf74929a4a61e119a32a181f534a11b47"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ecaf7b0e39caeb1aa6dd6e0975c405716c82c1312b55ac4f716ef563a906969"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5170be9ec942f3d1d317817ced8d749b3e1202670865e4fd465e35d8c259de83"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f1ae08fa9aea5891cbd69df29913e11d3841798e0bfb1ff78b78e4e7ea0a4"}, + {file = "tokenizers-0.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ee86d4095d3542d73579e953c2e5e07d9321af2ffea6ecc097d16d538a2dea16"}, + {file = "tokenizers-0.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:86dcd08da163912e17b27bbaba5efdc71b4fbffb841530fdb74c5707f3c49216"}, + {file = "tokenizers-0.20.1-cp311-none-win32.whl", hash = "sha256:9af2dc4ee97d037bc6b05fa4429ddc87532c706316c5e11ce2f0596dfcfa77af"}, + {file = "tokenizers-0.20.1-cp311-none-win_amd64.whl", hash = "sha256:899152a78b095559c287b4c6d0099469573bb2055347bb8154db106651296f39"}, + {file = "tokenizers-0.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:407ab666b38e02228fa785e81f7cf79ef929f104bcccf68a64525a54a93ceac9"}, + {file = "tokenizers-0.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f13a2d16032ebc8bd812eb8099b035ac65887d8f0c207261472803b9633cf3e"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e98eee4dca22849fbb56a80acaa899eec5b72055d79637dd6aa15d5e4b8628c9"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47c1bcdd61e61136087459cb9e0b069ff23b5568b008265e5cbc927eae3387ce"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128c1110e950534426e2274837fc06b118ab5f2fa61c3436e60e0aada0ccfd67"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2e2d47a819d2954f2c1cd0ad51bb58ffac6f53a872d5d82d65d79bf76b9896d"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd67a0e3503a9a7cf8bc5a4a49cdde5fa5bada09a51e4c7e1c73900297539bd"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689b93d2e26d04da337ac407acec8b5d081d8d135e3e5066a88edd5bdb5aff89"}, + {file = "tokenizers-0.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0c6a796ddcd9a19ad13cf146997cd5895a421fe6aec8fd970d69f9117bddb45c"}, + {file = "tokenizers-0.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3ea919687aa7001a8ff1ba36ac64f165c4e89035f57998fa6cedcfd877be619d"}, + {file = "tokenizers-0.20.1-cp312-none-win32.whl", hash = "sha256:6d3ac5c1f48358ffe20086bf065e843c0d0a9fce0d7f0f45d5f2f9fba3609ca5"}, + {file = "tokenizers-0.20.1-cp312-none-win_amd64.whl", hash = "sha256:b0874481aea54a178f2bccc45aa2d0c99cd3f79143a0948af6a9a21dcc49173b"}, + {file = "tokenizers-0.20.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:96af92e833bd44760fb17f23f402e07a66339c1dcbe17d79a9b55bb0cc4f038e"}, + {file = "tokenizers-0.20.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:65f34e5b731a262dfa562820818533c38ce32a45864437f3d9c82f26c139ca7f"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17f98fccb5c12ab1ce1f471731a9cd86df5d4bd2cf2880c5a66b229802d96145"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8c0fc3542cf9370bf92c932eb71bdeb33d2d4aeeb4126d9fd567b60bd04cb30"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b39356df4575d37f9b187bb623aab5abb7b62c8cb702867a1768002f814800c"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfdad27b0e50544f6b838895a373db6114b85112ba5c0cefadffa78d6daae563"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:094663dd0e85ee2e573126918747bdb40044a848fde388efb5b09d57bc74c680"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e4cf033a2aa207d7ac790e91adca598b679999710a632c4a494aab0fc3a1b2"}, + {file = "tokenizers-0.20.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9310951c92c9fb91660de0c19a923c432f110dbfad1a2d429fbc44fa956bf64f"}, + {file = "tokenizers-0.20.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05e41e302c315bd2ed86c02e917bf03a6cf7d2f652c9cee1a0eb0d0f1ca0d32c"}, + {file = "tokenizers-0.20.1-cp37-none-win32.whl", hash = "sha256:212231ab7dfcdc879baf4892ca87c726259fa7c887e1688e3f3cead384d8c305"}, + {file = "tokenizers-0.20.1-cp37-none-win_amd64.whl", hash = "sha256:896195eb9dfdc85c8c052e29947169c1fcbe75a254c4b5792cdbd451587bce85"}, + {file = "tokenizers-0.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:741fb22788482d09d68e73ece1495cfc6d9b29a06c37b3df90564a9cfa688e6d"}, + {file = "tokenizers-0.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10be14ebd8082086a342d969e17fc2d6edc856c59dbdbddd25f158fa40eaf043"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:514cf279b22fa1ae0bc08e143458c74ad3b56cd078b319464959685a35c53d5e"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a647c5b7cb896d6430cf3e01b4e9a2d77f719c84cefcef825d404830c2071da2"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cdf379219e1e1dd432091058dab325a2e6235ebb23e0aec8d0508567c90cd01"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ba72260449e16c4c2f6f3252823b059fbf2d31b32617e582003f2b18b415c39"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:910b96ed87316e4277b23c7bcaf667ce849c7cc379a453fa179e7e09290eeb25"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53975a6694428a0586534cc1354b2408d4e010a3103117f617cbb550299797c"}, + {file = "tokenizers-0.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:07c4b7be58da142b0730cc4e5fd66bb7bf6f57f4986ddda73833cd39efef8a01"}, + {file = "tokenizers-0.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b605c540753e62199bf15cf69c333e934077ef2350262af2ccada46026f83d1c"}, + {file = "tokenizers-0.20.1-cp38-none-win32.whl", hash = "sha256:88b3bc76ab4db1ab95ead623d49c95205411e26302cf9f74203e762ac7e85685"}, + {file = "tokenizers-0.20.1-cp38-none-win_amd64.whl", hash = "sha256:d412a74cf5b3f68a90c615611a5aa4478bb303d1c65961d22db45001df68afcb"}, + {file = "tokenizers-0.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a25dcb2f41a0a6aac31999e6c96a75e9152fa0127af8ece46c2f784f23b8197a"}, + {file = "tokenizers-0.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a12c3cebb8c92e9c35a23ab10d3852aee522f385c28d0b4fe48c0b7527d59762"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02e18da58cf115b7c40de973609c35bde95856012ba42a41ee919c77935af251"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f326a1ac51ae909b9760e34671c26cd0dfe15662f447302a9d5bb2d872bab8ab"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b4872647ea6f25224e2833b044b0b19084e39400e8ead3cfe751238b0802140"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce6238a3311bb8e4c15b12600927d35c267b92a52c881ef5717a900ca14793f7"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57b7a8880b208866508b06ce365dc631e7a2472a3faa24daa430d046fb56c885"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a908c69c2897a68f412aa05ba38bfa87a02980df70f5a72fa8490479308b1f2d"}, + {file = "tokenizers-0.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:da1001aa46f4490099c82e2facc4fbc06a6a32bf7de3918ba798010954b775e0"}, + {file = "tokenizers-0.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42c097390e2f0ed0a5c5d569e6669dd4e9fff7b31c6a5ce6e9c66a61687197de"}, + {file = "tokenizers-0.20.1-cp39-none-win32.whl", hash = "sha256:3d4d218573a3d8b121a1f8c801029d70444ffb6d8f129d4cca1c7b672ee4a24c"}, + {file = "tokenizers-0.20.1-cp39-none-win_amd64.whl", hash = "sha256:37d1e6f616c84fceefa7c6484a01df05caf1e207669121c66213cb5b2911d653"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48689da7a395df41114f516208d6550e3e905e1239cc5ad386686d9358e9cef0"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:712f90ea33f9bd2586b4a90d697c26d56d0a22fd3c91104c5858c4b5b6489a79"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:359eceb6a620c965988fc559cebc0a98db26713758ec4df43fb76d41486a8ed5"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d3caf244ce89d24c87545aafc3448be15870096e796c703a0d68547187192e1"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03b03cf8b9a32254b1bf8a305fb95c6daf1baae0c1f93b27f2b08c9759f41dee"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:218e5a3561561ea0f0ef1559c6d95b825308dbec23fb55b70b92589e7ff2e1e8"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f40df5e0294a95131cc5f0e0eb91fe86d88837abfbee46b9b3610b09860195a7"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:08aaa0d72bb65058e8c4b0455f61b840b156c557e2aca57627056624c3a93976"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:998700177b45f70afeb206ad22c08d9e5f3a80639dae1032bf41e8cbc4dada4b"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62f7fbd3c2c38b179556d879edae442b45f68312019c3a6013e56c3947a4e648"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31e87fca4f6bbf5cc67481b562147fe932f73d5602734de7dd18a8f2eee9c6dd"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:956f21d359ae29dd51ca5726d2c9a44ffafa041c623f5aa33749da87cfa809b9"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1fbbaf17a393c78d8aedb6a334097c91cb4119a9ced4764ab8cfdc8d254dc9f9"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ebe63e31f9c1a970c53866d814e35ec2ec26fda03097c486f82f3891cee60830"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:81970b80b8ac126910295f8aab2d7ef962009ea39e0d86d304769493f69aaa1e"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130e35e76f9337ed6c31be386e75d4925ea807055acf18ca1a9b0eec03d8fe23"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd28a8614f5c82a54ab2463554e84ad79526c5184cf4573bbac2efbbbcead457"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9041ee665d0fa7f5c4ccf0f81f5e6b7087f797f85b143c094126fc2611fec9d0"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:62eb9daea2a2c06bcd8113a5824af8ef8ee7405d3a71123ba4d52c79bb3d9f1a"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f861889707b54a9ab1204030b65fd6c22bdd4a95205deec7994dc22a8baa2ea4"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:89d5c337d74ea6e5e7dc8af124cf177be843bbb9ca6e58c01f75ea103c12c8a9"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0b7f515c83397e73292accdbbbedc62264e070bae9682f06061e2ddce67cacaf"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e0305fc1ec6b1e5052d30d9c1d5c807081a7bd0cae46a33d03117082e91908c"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc611e6ac0fa00a41de19c3bf6391a05ea201d2d22b757d63f5491ec0e67faa"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5ffe0d7f7bfcfa3b2585776ecf11da2e01c317027c8573c78ebcb8985279e23"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e7edb8ec12c100d5458d15b1e47c0eb30ad606a05641f19af7563bc3d1608c14"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:de291633fb9303555793cc544d4a86e858da529b7d0b752bcaf721ae1d74b2c9"}, + {file = "tokenizers-0.20.1.tar.gz", hash = "sha256:84edcc7cdeeee45ceedb65d518fffb77aec69311c9c8e30f77ad84da3025f002"}, ] [package.dependencies] @@ -3236,13 +3413,13 @@ telegram = ["requests"] [[package]] name = "typing-extensions" -version = "4.9.0" +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.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {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]] @@ -3263,13 +3440,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.30.1" +version = "0.32.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"}, - {file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, + {file = "uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82"}, + {file = "uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e"}, ] [package.dependencies] @@ -3601,4 +3778,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "df9afeda50e05cb62b322a047028a9b0851db197c4f379903c70adab3a98777a" +content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index ff6de49811..4a7f7d4d6a 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.111.0" +version = "1.119.1" description = "" authors = ["Hau Tran "] readme = "README.md" @@ -13,11 +13,11 @@ opencv-python-headless = ">=4.7.0.72,<5.0" pillow = ">=9.5.0,<11.0" fastapi-slim = ">=0.95.2,<1.0" uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"} -pydantic = "^1.10.8" +pydantic = "^2.0.0" +pydantic-settings = "^2.5.2" aiocache = ">=0.12.1,<1.0" rich = ">=13.4.2" ftfy = ">=6.1.1" -setuptools = "^68.0.0" python-multipart = ">=0.0.6,<1.0" orjson = ">=3.9.5" gunicorn = ">=21.1.0" @@ -51,7 +51,7 @@ onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"} optional = true [tool.poetry.group.openvino.dependencies] -onnxruntime-openvino = "^1.17.1" +onnxruntime-openvino = ">=1.17.1,<1.19.0" [tool.poetry.group.armnn] optional = true diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 6b8e55a236..552cca1f5e 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -13,11 +13,14 @@ fi : "${IMMICH_HOST:=[::]}" : "${IMMICH_PORT:=3003}" : "${MACHINE_LEARNING_WORKERS:=1}" +: "${MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S:=2}" gunicorn app.main:app \ -k app.config.CustomUvicornWorker \ + -c gunicorn_conf.py \ -b "$IMMICH_HOST":"$IMMICH_PORT" \ -w "$MACHINE_LEARNING_WORKERS" \ -t "$MACHINE_LEARNING_WORKER_TIMEOUT" \ --log-config-json log_conf.json \ + --keep-alive "$MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S" \ --graceful-timeout 0 diff --git a/mobile/.fvmrc b/mobile/.fvmrc index cf7449069c..ee6eaac06f 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.22.3" -} \ No newline at end of file + "flutter": "3.24.3" +} diff --git a/mobile/.isar-cargo.lock b/mobile/.isar-cargo.lock new file mode 100644 index 0000000000..a7b1dd37b9 --- /dev/null +++ b/mobile/.isar-cargo.lock @@ -0,0 +1,859 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "cc" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "float_next_after" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc612c5837986b7104a87a0df74a5460931f1c5274be12f8d0f40aa2f30d632" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "intmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee87fd093563344074bacf24faa0bb0227fb6969fb223e922db798516de924d6" + +[[package]] +name = "isar" +version = "0.0.0" +dependencies = [ + "dirs", + "intmap", + "isar-core", + "itertools", + "jni", + "ndk-context", + "objc", + "objc-foundation", + "once_cell", + "paste", + "serde_json", + "threadpool", + "unicode-segmentation", +] + +[[package]] +name = "isar-core" +version = "0.0.0" +dependencies = [ + "byteorder", + "cfg-if", + "crossbeam-channel", + "enum_dispatch", + "float_next_after", + "intmap", + "itertools", + "libc", + "mdbx-sys", + "once_cell", + "paste", + "rand", + "serde", + "serde_json", + "snafu", + "widestring", + "xxhash-rust", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jni" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "mdbx-sys" +version = "0.0.0" +dependencies = [ + "bindgen", + "cc", + "cmake", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ea5043e58958ee56f3e15a90aee535795cd7dfd319846288d93c5b57d85cbe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "snafu" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xxhash-rust" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index c959187bb5..ceaf9a6ab8 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.22.3", + "dart.flutterSdkPath": ".fvm/versions/3.24.3", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index fe5729fc60..80514f1603 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -36,8 +36,73 @@ analyzer: - openapi/** - lib/generated_plugin_registrant.dart -plugins: - - custom_lint + plugins: + - custom_lint + +custom_lint: + debug: true + rules: + - avoid_build_context_in_providers: false + - avoid_public_notifier_properties: false + - avoid_manual_providers_as_generated_provider_dependency: false + - unsupported_provider_value: false + - import_rule_photo_manager: + message: photo_manager must only be used in MediaRepositories + restrict: package:photo_manager + allowed: + # required / wanted + - 'lib/repositories/{album,asset,file}_media.repository.dart' + # acceptable exceptions for the time being + - lib/entities/asset.entity.dart # to provide local AssetEntity for now + - lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager + # refactor to make the providers and services testable + - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler + - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_isar: + message: isar must only be used in entities and repositories + restrict: package:isar + allowed: + # required / wanted + - lib/entities/*.entity.dart + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) + - integration_test/test_utils/general_helper.dart + - lib/main.dart + - lib/pages/common/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/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 + + - import_rule_openapi: + message: openapi must only be used through ApiRepositories + restrict: package:openapi + allowed: + # requried / wanted + - lib/repositories/*_api.repository.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... + # refactor + - lib/models/map/map_marker.model.dart + - lib/models/server_info/server_{config,disk_info,features,version}.model.dart + - lib/models/shared_link/shared_link.model.dart + - lib/providers/asset_viewer/asset_people.provider.dart + - lib/providers/authentication.provider.dart + - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart + - lib/providers/map/map_state.provider.dart + - lib/providers/search/{search,search_filter}.provider.dart + - lib/providers/websocket.provider.dart + - lib/routing/auth_guard.dart + - lib/services/{api,asset,backup,memory,oauth,search,shared_link,stack,trash}.service.dart + - lib/widgets/album/album_thumbnail_listtile.dart + - lib/widgets/forms/login/login_form.dart + - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart dart_code_metrics: metrics: diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index a26d055cba..52750232cc 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -46,7 +46,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index dc0e10ee82..17c2830b48 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true" + android:largeHeap="true" android:enableOnBackInvokedCallback="false"> + + - + @@ -14,13 +16,14 @@ - + - + + diff --git a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json new file mode 100644 index 0000000000..7391713b6f --- /dev/null +++ b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 2bfa4c9f1f..77dc24e006 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.111.0" + version_number: "1.119.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart new file mode 100644 index 0000000000..8b74b1a66f --- /dev/null +++ b/mobile/lib/constants/constants.dart @@ -0,0 +1 @@ +const int noDbId = -9223372036854775808; // from Isar diff --git a/mobile/lib/constants/filters.dart b/mobile/lib/constants/filters.dart new file mode 100644 index 0000000000..d9fa2920b7 --- /dev/null +++ b/mobile/lib/constants/filters.dart @@ -0,0 +1,799 @@ +import 'package:flutter/material.dart'; + +List filters = [ + //Original + const ColorFilter.matrix([ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Vintage + const ColorFilter.matrix([ + 0.8, + 0.1, + 0.1, + 0, + 20, + 0.1, + 0.8, + 0.1, + 0, + 20, + 0.1, + 0.1, + 0.8, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Mood + const ColorFilter.matrix([ + 1.2, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Crisp + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cool + const ColorFilter.matrix([ + 0.9, + 0, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Blush + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunkissed + const ColorFilter.matrix([ + 1.3, + 0, + 0.1, + 0, + 15, + 0, + 1.1, + 0.1, + 0, + 10, + 0, + 0, + 0.9, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Fresh + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 20, + 0, + 1.2, + 0, + 0, + 20, + 0, + 0, + 1.1, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Classic + const ColorFilter.matrix([ + 1.1, + 0, + -0.1, + 0, + 10, + -0.1, + 1.1, + 0.1, + 0, + 5, + 0, + -0.1, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lomo-ish + const ColorFilter.matrix([ + 1.5, + 0, + 0.1, + 0, + 0, + 0, + 1.45, + 0, + 0, + 0, + 0.1, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Nashville + const ColorFilter.matrix([ + 1.2, + 0.15, + -0.15, + 0, + 15, + 0.1, + 1.1, + 0.1, + 0, + 10, + -0.05, + 0.2, + 1.25, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Valencia + const ColorFilter.matrix([ + 1.15, + 0.1, + 0.1, + 0, + 20, + 0.1, + 1.1, + 0, + 0, + 10, + 0.1, + 0.1, + 1.2, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Clarendon + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 10, + 0, + 1.25, + 0, + 0, + 10, + 0, + 0, + 1.3, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Moon + const ColorFilter.matrix([ + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Willow + const ColorFilter.matrix([ + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Kodak + const ColorFilter.matrix([ + 1.3, + 0.1, + -0.1, + 0, + 10, + 0, + 1.25, + 0.1, + 0, + 10, + 0, + -0.1, + 1.1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Frost + const ColorFilter.matrix([ + 0.8, + 0.2, + 0.1, + 0, + 0, + 0.2, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Night Vision + const ColorFilter.matrix([ + 0.1, + 0.95, + 0.2, + 0, + 0, + 0.1, + 1.5, + 0.1, + 0, + 0, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunset + const ColorFilter.matrix([ + 1.5, + 0.2, + 0, + 0, + 0, + 0.1, + 0.9, + 0.1, + 0, + 0, + -0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Noir + const ColorFilter.matrix([ + 1.3, + -0.3, + 0.1, + 0, + 0, + -0.1, + 1.2, + -0.1, + 0, + 0, + 0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Dreamy + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 0, + 0.1, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.1, + 0, + 15, + 0, + 0, + 0, + 1, + 0, + ]), + //Sepia + const ColorFilter.matrix([ + 0.393, + 0.769, + 0.189, + 0, + 0, + 0.349, + 0.686, + 0.168, + 0, + 0, + 0.272, + 0.534, + 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Radium + const ColorFilter.matrix([ + 1.438, + -0.062, + -0.062, + 0, + 0, + -0.122, + 1.378, + -0.122, + 0, + 0, + -0.016, + -0.016, + 1.483, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Aqua + const ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.7873, + 0.2848, + 0.9278, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Purple Haze + const ColorFilter.matrix([ + 1.3, + 0, + 1.2, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0.2, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lemonade + const ColorFilter.matrix([ + 1.2, + 0.1, + 0, + 0, + 0, + 0, + 1.1, + 0.2, + 0, + 0, + 0.1, + 0, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Caramel + const ColorFilter.matrix([ + 1.6, + 0.2, + 0, + 0, + 0, + 0.1, + 1.3, + 0.1, + 0, + 0, + 0, + 0.1, + 0.9, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Peachy + const ColorFilter.matrix([ + 1.3, + 0.5, + 0, + 0, + 0, + 0.2, + 1.1, + 0.3, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Neon + const ColorFilter.matrix([ + 1, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cold Morning + const ColorFilter.matrix([ + 0.9, + 0.1, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lush + const ColorFilter.matrix([ + 0.9, + 0.2, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Urban Neon + const ColorFilter.matrix([ + 1.1, + 0, + 0.3, + 0, + 0, + 0, + 0.9, + 0.3, + 0, + 0, + 0.3, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Monochrome + const ColorFilter.matrix([ + 0.6, + 0.2, + 0.2, + 0, + 0, + 0.2, + 0.6, + 0.2, + 0, + 0, + 0.2, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), +]; + +const List filterNames = [ + 'Original', + 'Vintage', + 'Mood', + 'Crisp', + 'Cool', + 'Blush', + 'Sunkissed', + 'Fresh', + 'Classic', + 'Lomo-ish', + 'Nashville', + 'Valencia', + 'Clarendon', + 'Moon', + 'Willow', + 'Kodak', + 'Frost', + 'Night Vision', + 'Sunset', + 'Noir', + 'Dreamy', + 'Sepia', + 'Radium', + 'Aqua', + 'Purple Haze', + 'Lemonade', + 'Caramel', + 'Peachy', + 'Neon', + 'Cold Morning', + 'Lush', + 'Urban Neon', + 'Monochrome', +]; diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 598f956619..6f6d1a6a31 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -1,5 +1,111 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/immich_app_theme.dart'; -const Color immichBackgroundColor = Color(0xFFf6f8fe); -const Color immichDarkBackgroundColor = Color.fromARGB(255, 0, 0, 0); -const Color immichDarkThemePrimaryColor = Color.fromARGB(255, 173, 203, 250); +enum ImmichColorPreset { + indigo, + deepPurple, + pink, + red, + orange, + yellow, + lime, + green, + cyan, + slateGray +} + +const ImmichColorPreset defaultColorPreset = ImmichColorPreset.indigo; +const String defaultColorPresetName = "indigo"; + +const Color immichBrandColorLight = Color(0xFF4150AF); +const Color immichBrandColorDark = Color(0xFFACCBFA); + +final Map _themePresetsMap = { + ImmichColorPreset.indigo: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: immichBrandColorLight, + ).copyWith( + primary: immichBrandColorLight, + onSurface: const Color.fromARGB(255, 34, 31, 32), + ), + dark: ColorScheme.fromSeed( + seedColor: immichBrandColorDark, + brightness: Brightness.dark, + ).copyWith(primary: immichBrandColorDark), + ), + ImmichColorPreset.deepPurple: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF6F43C0)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFD3BBFF), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.pink: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFED79B5)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFED79B5), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.red: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFC51C16)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFD3302F), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.orange: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: const Color(0xffff5b01), + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFCC6D08), + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.fidelity, + ), + ), + ImmichColorPreset.yellow: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFFFB400)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFFFB400), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.lime: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFFCDDC39)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFFCDDC39), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.green: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF18C249)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFF18C249), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.cyan: ImmichTheme( + light: ColorScheme.fromSeed(seedColor: const Color(0xFF00BCD4)), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xFF00BCD4), + brightness: Brightness.dark, + ), + ), + ImmichColorPreset.slateGray: ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: const Color(0xFF696969), + dynamicSchemeVariant: DynamicSchemeVariant.neutral, + ), + dark: ColorScheme.fromSeed( + seedColor: const Color(0xff696969), + brightness: Brightness.dark, + dynamicSchemeVariant: DynamicSchemeVariant.neutral, + ), + ), +}; + +extension ImmichColorModeExtension on ImmichColorPreset { + ImmichTheme getTheme() => _themePresetsMap[this]!; +} diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index c05b849dcd..6331c4b9f0 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -1,11 +1,11 @@ import 'package:flutter/foundation.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/utils/datetime_comparison.dart'; import 'package:isar/isar.dart'; +// ignore: implementation_imports +import 'package:isar/src/common/isar_links_common.dart'; import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; part 'album.entity.g.dart'; @@ -25,6 +25,7 @@ class Album { required this.activityEnabled, }); + // fields stored in DB Id id = Isar.autoIncrement; @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -43,6 +44,17 @@ class Album { final IsarLinks sharedUsers = IsarLinks(); final IsarLinks assets = IsarLinks(); + // transient fields + @ignore + bool isAll = false; + + @ignore + String? remoteThumbnailAssetId; + + @ignore + int remoteAssetCount = 0; + + // getters @ignore bool get isRemote => remoteId != null; @@ -70,6 +82,21 @@ class Album { return name.join(' '); } + @ignore + String get eTagKeyAssetCount => "device-album-$localId-asset-count"; + + // the following getter are needed because Isar links do not make data + // accessible in an object freshly created (not loaded from DB) + + @ignore + Iterable get remoteUsers => sharedUsers.isEmpty + ? (sharedUsers as IsarLinksCommon).addedObjects + : sharedUsers; + + @ignore + Iterable get remoteAssets => + assets.isEmpty ? (assets as IsarLinksCommon).addedObjects : assets; + @override bool operator ==(other) { if (other is! Album) return false; @@ -112,19 +139,6 @@ class Album { sharedUsers.length.hashCode ^ assets.length.hashCode; - static Album local(AssetPathEntity ape) { - final Album a = Album( - name: ape.name, - createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), - modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), - shared: false, - activityEnabled: false, - ); - a.owner.value = Store.get(StoreKey.currentUser); - a.localId = ape.id; - return a; - } - static Future remote(AlbumResponseDto dto) async { final Isar db = Isar.getInstance()!; final Album a = Album( @@ -138,6 +152,7 @@ class Album { endDate: dto.endDate, activityEnabled: dto.isActivityEnabled, ); + a.remoteAssetCount = dto.assetCount; a.owner.value = await db.users.getById(dto.ownerId); if (dto.albumThumbnailAssetId != null) { a.thumbnail.value = await db.assets @@ -164,19 +179,12 @@ class Album { } extension AssetsHelper on IsarCollection { - Future store(Album a) async { + Future store(Album a) async { await put(a); await a.owner.save(); await a.thumbnail.save(); await a.sharedUsers.save(); await a.assets.save(); + return a; } } - -extension AlbumResponseDtoHelper on AlbumResponseDto { - List getAssets() => assets.map(Asset.remote).toList(); -} - -extension AssetPathEntityHelper on AssetPathEntity { - String get eTagKeyAssetCount => "device-album-$id-asset-count"; -} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 3f8c1fa74c..182c10307f 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,11 +1,10 @@ import 'dart:convert'; import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.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'; +import 'package:photo_manager/photo_manager.dart' show AssetEntity; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:path/path.dart' as p; @@ -23,8 +22,12 @@ class Asset { durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, - height = remote.exifInfo?.exifImageHeight?.toInt(), - width = remote.exifInfo?.exifImageWidth?.toInt(), + height = isFlipped(remote) + ? remote.exifInfo?.exifImageWidth?.toInt() + : remote.exifInfo?.exifImageHeight?.toInt(), + width = isFlipped(remote) + ? remote.exifInfo?.exifImageHeight?.toInt() + : remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), exifInfo = @@ -33,40 +36,15 @@ class Asset { isArchived = remote.isArchived, isTrashed = remote.isTrashed, isOffline = remote.isOffline, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId = - remote.stackParentId == remote.id ? null : remote.stackParentId, - stackCount = remote.stackCount, + stackPrimaryAssetId = remote.stack?.primaryAssetId == remote.id + ? null + : remote.stack?.primaryAssetId, + stackCount = remote.stack?.assetCount ?? 0, + stackId = remote.stack?.id, thumbhash = remote.thumbhash; - Asset.local(AssetEntity local, List hash) - : localId = local.id, - checksum = base64.encode(hash), - durationInSeconds = local.duration, - type = AssetType.values[local.typeInt], - height = local.height, - width = local.width, - fileName = local.title!, - ownerId = Store.get(StoreKey.currentUser).isarId, - fileModifiedAt = local.modifiedDateTime, - updatedAt = local.modifiedDateTime, - isFavorite = local.isFavorite, - isArchived = false, - isTrashed = false, - isOffline = false, - stackCount = 0, - fileCreatedAt = local.createDateTime { - if (fileCreatedAt.year == 1970) { - fileCreatedAt = fileModifiedAt; - } - if (local.latitude != null) { - exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); - } - _local = local; - assert(hash.length == 20, "invalid SHA1 hash"); - } - Asset({ this.id = Isar.autoIncrement, required this.checksum, @@ -86,7 +64,8 @@ class Asset { this.isFavorite = false, this.isArchived = false, this.isTrashed = false, - this.stackParentId, + this.stackId, + this.stackPrimaryAssetId, this.stackCount = 0, this.isOffline = false, this.thumbhash, @@ -112,6 +91,8 @@ class Asset { return _local; } + set local(AssetEntity? assetEntity) => _local = assetEntity; + Id id = Isar.autoIncrement; /// stores the raw SHA1 bytes as a base64 String @@ -163,12 +144,11 @@ class Asset { @ignore ExifInfo? exifInfo; - String? stackParentId; + String? stackId; - @ignore - int get stackChildrenCount => stackCount ?? 0; + String? stackPrimaryAssetId; - int? stackCount; + int stackCount; /// Aspect ratio of the asset @ignore @@ -208,6 +188,10 @@ class Asset { @ignore Duration get duration => Duration(seconds: durationInSeconds); + // ignore: invalid_annotation_target + @ignore + set byteHash(List hash) => checksum = base64.encode(hash); + @override bool operator ==(other) { if (other is! Asset) return false; @@ -231,7 +215,8 @@ class Asset { isArchived == other.isArchived && isTrashed == other.isTrashed && stackCount == other.stackCount && - stackParentId == other.stackParentId; + stackPrimaryAssetId == other.stackPrimaryAssetId && + stackId == other.stackId; } @override @@ -256,7 +241,8 @@ class Asset { isArchived.hashCode ^ isTrashed.hashCode ^ stackCount.hashCode ^ - stackParentId.hashCode; + stackPrimaryAssetId.hashCode ^ + stackId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -269,7 +255,6 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || - stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || isTrashed != a.isTrashed || @@ -278,10 +263,9 @@ class Asset { a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote a.thumbhash != thumbhash || - ((stackCount == null && a.stackCount != null) || - (stackCount != null && - a.stackCount != null && - stackCount != a.stackCount)); + stackId != a.stackId || + stackCount != a.stackCount || + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -311,9 +295,11 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: stackParentId == remoteId ? null : stackParentId, + stackId: stackId, + stackPrimaryAssetId: + stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId, stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, @@ -330,9 +316,12 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, - // workaround to nullify stackParentId for the parent asset until we refactor the mobile app + // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app // stack handling to properly handle it - stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId, + stackId: a.stackId, + stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId + ? null + : a.stackPrimaryAssetId, stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, @@ -374,7 +363,8 @@ class Asset { bool? isTrashed, bool? isOffline, ExifInfo? exifInfo, - String? stackParentId, + String? stackId, + String? stackPrimaryAssetId, int? stackCount, String? thumbhash, }) => @@ -398,7 +388,8 @@ class Asset { isTrashed: isTrashed ?? this.isTrashed, isOffline: isOffline ?? this.isOffline, exifInfo: exifInfo ?? this.exifInfo, - stackParentId: stackParentId ?? this.stackParentId, + stackId: stackId ?? this.stackId, + stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, ); @@ -445,8 +436,9 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackId": "${stackId ?? "N/A"}", + "stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}", "stackCount": "$stackCount", - "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", @@ -519,3 +511,21 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } + +/// Returns `true` if this [int] is flipped 90° clockwise +bool isRotated90CW(int orientation) { + return [7, 8, -90].contains(orientation); +} + +/// Returns `true` if this [int] is flipped 270° clockwise +bool isRotated270CW(int orientation) { + return [5, 6, 90].contains(orientation); +} + +/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise +bool isFlipped(AssetResponseDto response) { + final int orientation = + int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0; + return orientation != 0 && + (isRotated90CW(orientation) || isRotated270CW(orientation)); +} diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 099e15eef1..23bf236046 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -92,29 +92,34 @@ const AssetSchema = CollectionSchema( name: r'stackCount', type: IsarType.long, ), - r'stackParentId': PropertySchema( + r'stackId': PropertySchema( id: 15, - name: r'stackParentId', + name: r'stackId', + type: IsarType.string, + ), + r'stackPrimaryAssetId': PropertySchema( + id: 16, + name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -205,7 +210,13 @@ int _assetEstimateSize( } } { - final value = object.stackParentId; + final value = object.stackId; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.stackPrimaryAssetId; if (value != null) { bytesCount += 3 + value.length * 3; } @@ -240,11 +251,12 @@ void _assetSerialize( writer.writeLong(offsets[12], object.ownerId); writer.writeString(offsets[13], object.remoteId); writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackParentId); - writer.writeString(offsets[16], object.thumbhash); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeString(offsets[15], object.stackId); + writer.writeString(offsets[16], object.stackPrimaryAssetId); + writer.writeString(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -269,13 +281,14 @@ Asset _assetDeserialize( localId: reader.readStringOrNull(offsets[11]), ownerId: reader.readLong(offsets[12]), remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]), - stackParentId: reader.readStringOrNull(offsets[15]), - thumbhash: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + stackCount: reader.readLongOrNull(offsets[14]) ?? 0, + stackId: reader.readStringOrNull(offsets[15]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), + thumbhash: reader.readStringOrNull(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -316,17 +329,19 @@ P _assetDeserializeProp

( case 13: return (reader.readStringOrNull(offset)) as P; case 14: - return (reader.readLongOrNull(offset)) as P; + return (reader.readLongOrNull(offset) ?? 0) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readStringOrNull(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1859,24 +1874,8 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackCountIsNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackCount', - )); - }); - } - - QueryBuilder stackCountIsNotNull() { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackCount', - )); - }); - } - QueryBuilder stackCountEqualTo( - int? value) { + int value) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( property: r'stackCount', @@ -1886,7 +1885,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountGreaterThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1899,7 +1898,7 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountLessThan( - int? value, { + int value, { bool include = false, }) { return QueryBuilder.apply(this, (query) { @@ -1912,8 +1911,8 @@ extension AssetQueryFilter on QueryBuilder { } QueryBuilder stackCountBetween( - int? lower, - int? upper, { + int lower, + int upper, { bool includeLower = true, bool includeUpper = true, }) { @@ -1928,36 +1927,36 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdIsNull() { + QueryBuilder stackIdIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdIsNotNull() { + QueryBuilder stackIdIsNotNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNotNull( - property: r'stackParentId', + property: r'stackId', )); }); } - QueryBuilder stackParentIdEqualTo( + QueryBuilder stackIdEqualTo( String? value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdGreaterThan( + QueryBuilder stackIdGreaterThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1965,14 +1964,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdLessThan( + QueryBuilder stackIdLessThan( String? value, { bool include = false, bool caseSensitive = true, @@ -1980,14 +1979,14 @@ extension AssetQueryFilter on QueryBuilder { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.lessThan( include: include, - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdBetween( + QueryBuilder stackIdBetween( String? lower, String? upper, { bool includeLower = true, @@ -1996,7 +1995,7 @@ extension AssetQueryFilter on QueryBuilder { }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.between( - property: r'stackParentId', + property: r'stackId', lower: lower, includeLower: includeLower, upper: upper, @@ -2006,69 +2005,221 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder stackParentIdStartsWith( + QueryBuilder stackIdStartsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.startsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdEndsWith( + QueryBuilder stackIdEndsWith( String value, { bool caseSensitive = true, }) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.endsWith( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdContains( + QueryBuilder stackIdContains( String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.contains( - property: r'stackParentId', + property: r'stackId', value: value, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdMatches( + QueryBuilder stackIdMatches( String pattern, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.matches( - property: r'stackParentId', + property: r'stackId', wildcard: pattern, caseSensitive: caseSensitive, )); }); } - QueryBuilder stackParentIdIsEmpty() { + QueryBuilder stackIdIsEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.equalTo( - property: r'stackParentId', + property: r'stackId', value: '', )); }); } - QueryBuilder stackParentIdIsNotEmpty() { + QueryBuilder stackIdIsNotEmpty() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(FilterCondition.greaterThan( - property: r'stackParentId', + property: r'stackId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'stackPrimaryAssetId', + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'stackPrimaryAssetId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'stackPrimaryAssetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackPrimaryAssetIdMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'stackPrimaryAssetId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackPrimaryAssetId', + value: '', + )); + }); + } + + QueryBuilder + stackPrimaryAssetIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'stackPrimaryAssetId', value: '', )); }); @@ -2580,15 +2731,27 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByStackParentId() { + QueryBuilder sortByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder sortByStackParentIdDesc() { + QueryBuilder sortByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder sortByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder sortByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2834,15 +2997,27 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByStackParentId() { + QueryBuilder thenByStackId() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.asc); + return query.addSortBy(r'stackId', Sort.asc); }); } - QueryBuilder thenByStackParentIdDesc() { + QueryBuilder thenByStackIdDesc() { return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'stackParentId', Sort.desc); + return query.addSortBy(r'stackId', Sort.desc); + }); + } + + QueryBuilder thenByStackPrimaryAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.asc); + }); + } + + QueryBuilder thenByStackPrimaryAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackPrimaryAssetId', Sort.desc); }); } @@ -2992,10 +3167,17 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByStackParentId( + QueryBuilder distinctByStackId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'stackParentId', + return query.addDistinctBy(r'stackId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByStackPrimaryAssetId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackPrimaryAssetId', caseSensitive: caseSensitive); }); } @@ -3117,15 +3299,21 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder stackCountProperty() { + QueryBuilder stackCountProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'stackCount'); }); } - QueryBuilder stackParentIdProperty() { + QueryBuilder stackIdProperty() { return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'stackParentId'); + return query.addPropertyName(r'stackId'); + }); + } + + QueryBuilder stackPrimaryAssetIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackPrimaryAssetId'); }); } diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index c03c410f69..63d06f5d2c 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -170,6 +170,30 @@ class ExifInfo { state.hashCode ^ country.hashCode ^ description.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, +}"""; + } } double? _exposureTimeToSeconds(String? s) { diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index baa7ff51a3..1dda2b9a12 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -229,6 +229,13 @@ enum StoreKey { 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), ; const StoreKey( diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index 6a61b00530..141a1ede15 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -20,10 +20,10 @@ extension ContextHelper on BuildContext { bool get isDarkTheme => themeData.brightness == Brightness.dark; // Returns the current Primary color of the Theme - Color get primaryColor => themeData.primaryColor; + Color get primaryColor => themeData.colorScheme.primary; // Returns the Scaffold background color of the Theme - Color get scaffoldBackgroundColor => themeData.scaffoldBackgroundColor; + Color get scaffoldBackgroundColor => colorScheme.surface; // Returns the current TextTheme TextTheme get textTheme => themeData.textTheme; diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 769bec472b..d27c9e9500 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -70,18 +70,6 @@ extension AssetListExtension on Iterable { } return this; } - - /// Filters out offline assets and returns those that are still accessible by the Immich server - Iterable nonOfflineOnly({ - void Function()? errorCallback, - }) { - final bool onlyLive = every((e) => !e.isOffline); - if (!onlyLive) { - if (errorCallback != null) errorCallback(); - return where((a) => !a.isOffline); - } - return this; - } } extension SortedByProperty on Iterable { diff --git a/mobile/lib/extensions/theme_extensions.dart b/mobile/lib/extensions/theme_extensions.dart new file mode 100644 index 0000000000..3e17e2b991 --- /dev/null +++ b/mobile/lib/extensions/theme_extensions.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +extension ImmichColorSchemeExtensions on ColorScheme { + bool get _isDarkMode => brightness == Brightness.dark; + Color get onSurfaceSecondary => _isDarkMode + ? onSurface.darken(amount: .3) + : onSurface.lighten(amount: .3); +} + +extension ColorExtensions on Color { + Color lighten({double amount = 0.1}) { + return Color.alphaBlend( + Colors.white.withOpacity(amount), + this, + ); + } + + Color darken({double amount = 0.1}) { + return Color.alphaBlend( + Colors.black.withOpacity(amount), + this, + ); + } +} diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart new file mode 100644 index 0000000000..99aef6f4d4 --- /dev/null +++ b/mobile/lib/interfaces/activity_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/models/activities/activity.model.dart'; + +abstract interface class IActivityApiRepository { + Future> getAll( + String albumId, { + String? assetId, + }); + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }); + Future delete(String id); + Future getStats(String albumId, {String? assetId}); +} diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart new file mode 100644 index 0000000000..bdf11f18de --- /dev/null +++ b/mobile/lib/interfaces/album.interface.dart @@ -0,0 +1,46 @@ +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/database.interface.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; + +abstract interface class IAlbumRepository implements IDatabaseRepository { + Future create(Album album); + + Future get(int id); + + Future getByName( + String name, { + bool? shared, + bool? remote, + }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + + Future update(Album album); + + Future delete(int albumId); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + + Future removeUsers(Album album, List users); + + Future addAssets(Album album, List assets); + + Future removeAssets(Album album, List assets); + + Future recalculateMetadata(Album album); + + Future> search(String searchTerm, QuickFilterMode filterMode); +} + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/album_api.interface.dart b/mobile/lib/interfaces/album_api.interface.dart new file mode 100644 index 0000000000..33b589841f --- /dev/null +++ b/mobile/lib/interfaces/album_api.interface.dart @@ -0,0 +1,40 @@ +import 'package:immich_mobile/entities/album.entity.dart'; + +abstract interface class IAlbumApiRepository { + Future get(String id); + + Future> getAll({bool? shared}); + + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }); + + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }); + + Future delete(String albumId); + + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ); + + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ); + + Future addUsers( + String albumId, + Iterable userIds, + ); + + Future removeUser(String albumId, {required String userId}); +} diff --git a/mobile/lib/interfaces/album_media.interface.dart b/mobile/lib/interfaces/album_media.interface.dart new file mode 100644 index 0000000000..fd5f3c8af1 --- /dev/null +++ b/mobile/lib/interfaces/album_media.interface.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAlbumMediaRepository { + Future> getAll(); + + Future> getAssetIds(String albumId); + + Future getAssetCount(String albumId); + + Future> getAssets( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }); + + Future get(String id); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart new file mode 100644 index 0000000000..5aec594eb1 --- /dev/null +++ b/mobile/lib/interfaces/asset.interface.dart @@ -0,0 +1,62 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAssetRepository implements IDatabaseRepository { + Future getByRemoteId(String id); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + + Future> getAll({ + required int ownerId, + AssetState? state, + AssetSort? sortBy, + int? limit, + }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }); + + Future update(Asset asset); + + Future> updateAll(List assets); + + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + + Future> getMatches({ + required List assets, + required int ownerId, + AssetState? state, + int limit = 100, + }); + + Future> getDeviceAssetsById(List ids); + + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); +} + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart new file mode 100644 index 0000000000..fe3320c9bb --- /dev/null +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -0,0 +1,18 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetApiRepository { + // Future get(String id); + + // Future> getAll(); + + // Future create(Asset asset); + + Future update( + String id, { + String? description, + }); + + // Future delete(String id); + + Future> search({List personIds = const []}); +} diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart new file mode 100644 index 0000000000..2606d5c23c --- /dev/null +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetMediaRepository { + Future> deleteAll(List ids); + + Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); +} diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart new file mode 100644 index 0000000000..c32199a58f --- /dev/null +++ b/mobile/lib/interfaces/backup.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAll({BackupAlbumSort? sort}); + + Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); +} + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000..5645d15c47 --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000..dc4f0f57f8 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000..e567235d1b --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IETagRepository implements IDatabaseRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart new file mode 100644 index 0000000000..86608c26d0 --- /dev/null +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -0,0 +1,12 @@ +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/file_media.interface.dart b/mobile/lib/interfaces/file_media.interface.dart new file mode 100644 index 0000000000..ea01819dc3 --- /dev/null +++ b/mobile/lib/interfaces/file_media.interface.dart @@ -0,0 +1,36 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IFileMediaRepository { + Future saveImage( + Uint8List data, { + required String title, + String? relativePath, + }); + + Future saveImageWithFile( + String filePath, { + String? title, + String? relativePath, + }); + + Future saveVideo( + File file, { + required String title, + String? relativePath, + }); + + Future saveLivePhoto({ + required File image, + required File video, + required String title, + }); + + Future clearFileCache(); + + Future enableBackgroundAccess(); + + Future requestExtendedPermissions(); +} diff --git a/mobile/lib/interfaces/partner_api.interface.dart b/mobile/lib/interfaces/partner_api.interface.dart new file mode 100644 index 0000000000..bca1baf66d --- /dev/null +++ b/mobile/lib/interfaces/partner_api.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IPartnerApiRepository { + Future> getAll(Direction direction); + Future create(String id); + Future update(String id, {required bool inTimeline}); + Future delete(String id); +} + +enum Direction { + sharedWithMe, + sharedByMe, +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart new file mode 100644 index 0000000000..b2fa28df8c --- /dev/null +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -0,0 +1,22 @@ +abstract interface class IPersonApiRepository { + Future> getAll(); + Future update(String id, {String? name}); +} + +class Person { + Person({ + required this.id, + required this.isHidden, + required this.name, + required this.thumbnailPath, + this.birthDate, + this.updatedAt, + }); + + final String id; + final DateTime? birthDate; + final bool isHidden; + final String name; + final String thumbnailPath; + final DateTime? updatedAt; +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart new file mode 100644 index 0000000000..e6175a7dc9 --- /dev/null +++ b/mobile/lib/interfaces/user.interface.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IUserRepository implements IDatabaseRepository { + Future get(String id); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sortBy}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + + Future update(User user); + + Future deleteById(List ids); + + Future me(); +} + +enum UserSort { id } diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/interfaces/user_api.interface.dart new file mode 100644 index 0000000000..67ac3c0883 --- /dev/null +++ b/mobile/lib/interfaces/user_api.interface.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserApiRepository { + Future> getAll(); + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 241536c33a..6d4c78f879 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; 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:flutter/foundation.dart'; @@ -10,6 +11,7 @@ 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:share_handler/share_handler.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -40,7 +42,6 @@ import 'package:path_provider/path_provider.dart'; void main() async { ImmichWidgetsBinding(); - final db = await loadDb(); await initApp(); await migrateDatabaseIfNeeded(db); @@ -66,6 +67,8 @@ Future initApp() async { } } + await fetchSystemPalette(); + // Initialize Immich Logger Service ImmichLogger(); @@ -81,11 +84,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error \n $stack"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -210,7 +231,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, @@ -220,9 +242,9 @@ class ImmichAppState extends ConsumerState home: MaterialApp.router( title: 'Immich', debugShowCheckedModeBanner: false, - themeMode: ref.watch(immichThemeProvider), - darkTheme: immichDarkTheme, - theme: immichLightTheme, + themeMode: ref.watch(immichThemeModeProvider), + darkTheme: getThemeData(colorScheme: immichTheme.dark), + theme: getThemeData(colorScheme: immichTheme.light), routeInformationParser: router.defaultRouteParser(), routerDelegate: router.delegate( navigatorObservers: () => [TabNavigationObserver(ref: ref)], diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index 6adb80dca9..4702753f41 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:openapi/api.dart'; enum ActivityType { comment, like } @@ -38,16 +37,6 @@ class Activity { ); } - Activity.fromDto(ActivityResponseDto dto) - : id = dto.id, - assetId = dto.assetId, - comment = dto.comment, - createdAt = dto.createdAt, - type = dto.type == ReactionType.comment - ? ActivityType.comment - : ActivityType.like, - user = User.fromSimpleUserDto(dto.user); - @override String toString() { return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; @@ -75,3 +64,9 @@ class Activity { user.hashCode; } } + +class ActivityStats { + final int comments; + + const ActivityStats({required this.comments}); +} diff --git a/mobile/lib/models/albums/album_search.model.dart b/mobile/lib/models/albums/album_search.model.dart new file mode 100644 index 0000000000..ac4eedbff1 --- /dev/null +++ b/mobile/lib/models/albums/album_search.model.dart @@ -0,0 +1,5 @@ +enum QuickFilterMode { + all, + sharedWithMe, + myAlbums, +} diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f8..0000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/backup/available_album.model.dart b/mobile/lib/models/backup/available_album.model.dart index 0b428eea0f..59c57582ce 100644 --- a/mobile/lib/models/backup/available_album.model.dart +++ b/mobile/lib/models/backup/available_album.model.dart @@ -1,45 +1,47 @@ import 'dart:typed_data'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; class AvailableAlbum { - final AssetPathEntity albumEntity; + final Album album; + final int assetCount; final DateTime? lastBackup; AvailableAlbum({ - required this.albumEntity, + required this.album, + required this.assetCount, this.lastBackup, }); AvailableAlbum copyWith({ - AssetPathEntity? albumEntity, + Album? album, + int? assetCount, DateTime? lastBackup, Uint8List? thumbnailData, }) { return AvailableAlbum( - albumEntity: albumEntity ?? this.albumEntity, + album: album ?? this.album, + assetCount: assetCount ?? this.assetCount, lastBackup: lastBackup ?? this.lastBackup, ); } - String get name => albumEntity.name; + String get name => album.name; - Future get assetCount => albumEntity.assetCountAsync; + String get id => album.localId!; - String get id => albumEntity.id; - - bool get isAll => albumEntity.isAll; + bool get isAll => album.isAll; @override String toString() => - 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)'; + 'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)'; @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AvailableAlbum && other.albumEntity == albumEntity; + return other is AvailableAlbum && other.album == album; } @override - int get hashCode => albumEntity.hashCode; + int get hashCode => album.hashCode; } diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart new file mode 100644 index 0000000000..01c257dc05 --- /dev/null +++ b/mobile/lib/models/backup/backup_candidate.model.dart @@ -0,0 +1,19 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +class BackupCandidate { + BackupCandidate({required this.asset, required this.albumNames}); + + Asset asset; + List albumNames; + + @override + int get hashCode => asset.hashCode; + + @override + bool operator ==(Object other) { + if (other is! BackupCandidate) { + return false; + } + return asset == other.asset; + } +} diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index bb693a5b75..d829f411fc 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -2,7 +2,7 @@ import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -41,7 +41,7 @@ class BackUpState { final Set excludedBackupAlbums; /// Assets that are not overlapping in selected backup albums and excluded backup albums - final Set allUniqueAssets; + final Set allUniqueAssets; /// All assets from the selected albums that have been backup final Set selectedAlbumsBackupAssetsIds; @@ -94,7 +94,7 @@ class BackUpState { List? availableAlbums, Set? selectedBackupAlbums, Set? excludedBackupAlbums, - Set? allUniqueAssets, + Set? allUniqueAssets, Set? selectedAlbumsBackupAssetsIds, CurrentUploadAsset? currentUploadAsset, }) { diff --git a/mobile/lib/models/backup/error_upload_asset.model.dart b/mobile/lib/models/backup/error_upload_asset.model.dart index b63592eda8..38f241e748 100644 --- a/mobile/lib/models/backup/error_upload_asset.model.dart +++ b/mobile/lib/models/backup/error_upload_asset.model.dart @@ -1,11 +1,11 @@ -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; class ErrorUploadAsset { final String id; final DateTime fileCreatedAt; final String fileName; final String fileType; - final AssetEntity asset; + final Asset asset; final String errorMessage; const ErrorUploadAsset({ @@ -22,7 +22,7 @@ class ErrorUploadAsset { DateTime? fileCreatedAt, String? fileName, String? fileType, - AssetEntity? asset, + Asset? asset, String? errorMessage, }) { return ErrorUploadAsset( diff --git a/mobile/lib/models/backup/success_upload_asset.model.dart b/mobile/lib/models/backup/success_upload_asset.model.dart new file mode 100644 index 0000000000..045715e8cb --- /dev/null +++ b/mobile/lib/models/backup/success_upload_asset.model.dart @@ -0,0 +1,42 @@ +import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; + +class SuccessUploadAsset { + final BackupCandidate candidate; + final String remoteAssetId; + final bool isDuplicate; + + SuccessUploadAsset({ + required this.candidate, + required this.remoteAssetId, + required this.isDuplicate, + }); + + SuccessUploadAsset copyWith({ + BackupCandidate? candidate, + String? remoteAssetId, + bool? isDuplicate, + }) { + return SuccessUploadAsset( + candidate: candidate ?? this.candidate, + remoteAssetId: remoteAssetId ?? this.remoteAssetId, + isDuplicate: isDuplicate ?? this.isDuplicate, + ); + } + + @override + String toString() => + 'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)'; + + @override + bool operator ==(covariant SuccessUploadAsset other) { + if (identical(this, other)) return true; + + return other.candidate == candidate && + other.remoteAssetId == remoteAssetId && + other.isDuplicate == isDuplicate; + } + + @override + int get hashCode => + candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode; +} diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000..edd2fa183e --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000..9c0c7ae4e9 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 6a7c612b15..47baf356b7 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { String? country; @@ -235,7 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -258,7 +258,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, @@ -266,8 +266,8 @@ class SearchFilter { AssetType? mediaType, }) { return SearchFilter( - context: context ?? this.context, - filename: filename ?? this.filename, + context: context, + filename: filename, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, diff --git a/mobile/lib/models/search/search_result.model.dart b/mobile/lib/models/search/search_result.model.dart new file mode 100644 index 0000000000..f51353ad61 --- /dev/null +++ b/mobile/lib/models/search/search_result.model.dart @@ -0,0 +1,37 @@ +import 'package:collection/collection.dart'; + +import 'package:immich_mobile/entities/asset.entity.dart'; + +class SearchResult { + final List assets; + final int? nextPage; + + SearchResult({ + required this.assets, + this.nextPage, + }); + + SearchResult copyWith({ + List? assets, + int? nextPage, + }) { + return SearchResult( + assets: assets ?? this.assets, + nextPage: nextPage ?? this.nextPage, + ); + } + + @override + String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)'; + + @override + bool operator ==(covariant SearchResult other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.assets, assets) && other.nextPage == nextPage; + } + + @override + int get hashCode => assets.hashCode ^ nextPage.hashCode; +} diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 8936939135..f07ffde522 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,11 +4,15 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String mapDarkStyleUrl; + final String mapLightStyleUrl; const ServerConfig({ required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, }); ServerConfig copyWith({ @@ -20,6 +24,8 @@ class ServerConfig { trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + mapDarkStyleUrl: mapDarkStyleUrl, + mapLightStyleUrl: mapLightStyleUrl, ); } @@ -30,7 +36,9 @@ class ServerConfig { ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain; + externalDomain = dto.externalDomain, + mapDarkStyleUrl = dto.mapDarkStyleUrl, + mapLightStyleUrl = dto.mapLightStyleUrl; @override bool operator ==(covariant ServerConfig other) { diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart new file mode 100644 index 0000000000..e466149ac3 --- /dev/null +++ b/mobile/lib/pages/albums/albums.page.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; +import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class AlbumsPage extends HookConsumerWidget { + const AlbumsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = + ref.watch(albumProvider).where((album) => album.isRemote).toList(); + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + final sorted = albumSortOption.sortFn(albums, albumSortIsReverse); + final isGrid = useState(false); + final searchController = useTextEditingController(); + final debounceTimer = useRef(null); + final filterMode = useState(QuickFilterMode.all); + final userId = ref.watch(currentUserProvider)?.id; + final searchFocusNode = useFocusNode(); + + toggleViewMode() { + isGrid.value = !isGrid.value; + } + + onSearch(String searchTerm, QuickFilterMode mode) { + debounceTimer.value?.cancel(); + debounceTimer.value = Timer(const Duration(milliseconds: 300), () { + ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode); + }); + } + + changeFilter(QuickFilterMode mode) { + filterMode.value = mode; + } + + useEffect( + () { + searchController.addListener(() { + onSearch(searchController.text, filterMode.value); + }); + + return () { + searchController.removeListener(() { + onSearch(searchController.text, filterMode.value); + }); + debounceTimer.value?.cancel(); + }; + }, + [], + ); + + clearSearch() { + filterMode.value = QuickFilterMode.all; + searchController.clear(); + onSearch('', QuickFilterMode.all); + } + + return Scaffold( + appBar: ImmichAppBar( + showUploadButton: false, + actions: [ + IconButton( + icon: Icon( + Icons.add_rounded, + size: 28, + ), + onPressed: () => context.pushRoute( + CreateAlbumRoute(), + ), + ), + ], + ), + body: RefreshIndicator( + displacement: 70, + onRefresh: () async { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + }, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + children: [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + 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), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: GradientRotation(0.5 * pi), + ), + ), + child: TextField( + autofocus: false, + decoration: InputDecoration( + contentPadding: EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), + hintText: 'search_albums'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: clearSearch, + ) + : const SizedBox.shrink(), + ), + controller: searchController, + onChanged: (_) => + onSearch(searchController.text, filterMode.value), + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + QuickFilterButton( + label: 'all'.tr(), + isSelected: filterMode.value == QuickFilterMode.all, + onTap: () { + changeFilter(QuickFilterMode.all); + onSearch(searchController.text, QuickFilterMode.all); + }, + ), + QuickFilterButton( + label: 'shared_with_me'.tr(), + isSelected: filterMode.value == QuickFilterMode.sharedWithMe, + onTap: () { + changeFilter(QuickFilterMode.sharedWithMe); + onSearch( + searchController.text, + QuickFilterMode.sharedWithMe, + ); + }, + ), + QuickFilterButton( + label: 'my_albums'.tr(), + isSelected: filterMode.value == QuickFilterMode.myAlbums, + onTap: () { + changeFilter(QuickFilterMode.myAlbums); + onSearch( + searchController.text, + QuickFilterMode.myAlbums, + ); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortButton(), + IconButton( + icon: Icon( + isGrid.value + ? Icons.view_list_outlined + : Icons.grid_view_outlined, + size: 24, + ), + onPressed: toggleViewMode, + ), + ], + ), + const SizedBox(height: 5), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isGrid.value + ? GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: .7, + ), + itemBuilder: (context, index) { + return AlbumThumbnailCard( + album: sorted[index], + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + showOwner: true, + ); + }, + itemCount: sorted.length, + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + title: Text( + sorted[index].name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: sorted[index].ownerId == userId + ? Text( + '${sorted[index].assetCount} items', + overflow: TextOverflow.ellipsis, + style: + context.textTheme.bodyMedium?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : sorted[index].ownerName != null + ? Text( + '${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr( + args: [ + sorted[index].ownerName!, + ], + )}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : null, + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: ImmichThumbnail( + asset: sorted[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + // minVerticalPadding: 1, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class QuickFilterButton extends StatelessWidget { + const QuickFilterButton({ + super.key, + required this.isSelected, + required this.onTap, + required this.label, + }); + + final bool isSelected; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onTap, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + isSelected ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + fontSize: 14, + ), + ), + ); + } +} + +class SortButton extends ConsumerWidget { + const SortButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + + return MenuAnchor( + style: MenuStyle( + elevation: WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + consumeOutsideTap: true, + menuChildren: AlbumSortMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: albumSortOption == mode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () { + final selected = albumSortOption == mode; + // Switch direction + if (selected) { + ref + .read(albumSortOrderProvider.notifier) + .changeSortDirection(!albumSortIsReverse); + } else { + ref + .read(albumSortByOptionsProvider.notifier) + .changeSortMode(mode); + } + }, + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.fromLTRB(16, 16, 32, 16), + ), + backgroundColor: WidgetStateProperty.all( + albumSortOption == mode + ? context.colorScheme.primary + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + child: Text( + mode.label.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface.withAlpha(185), + ), + ), + ), + ) + .toList(), + builder: (context, controller, child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5), + child: Transform.rotate( + angle: 90 * pi / 180, + child: Icon( + Icons.compare_arrows_rounded, + size: 18, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ), + Text( + albumSortOption.label.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/pages/backup/album_preview.page.dart b/mobile/lib/pages/backup/album_preview.page.dart index 218127ff43..b9fed41305 100644 --- a/mobile/lib/pages/backup/album_preview.page.dart +++ b/mobile/lib/pages/backup/album_preview.page.dart @@ -1,26 +1,27 @@ -import 'dart:typed_data'; - 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/widgets/common/immich_loading_indicator.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/album.entity.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/repositories/album_media.repository.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; @RoutePage() class AlbumPreviewPage extends HookConsumerWidget { - final AssetPathEntity album; + final Album album; const AlbumPreviewPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final assets = useState>([]); + final assets = useState>([]); getAssetsInAlbum() async { - assets.value = await album.getAssetListRange( - start: 0, - end: await album.assetCountAsync, - ); + assets.value = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.localId!); } useEffect( @@ -46,7 +47,7 @@ class AlbumPreviewPage extends HookConsumerWidget { "ID ${album.id}", style: TextStyle( fontSize: 10, - color: Colors.grey[600], + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), @@ -66,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget { ), itemCount: assets.value.length, itemBuilder: (context, index) { - Future thumbData = - assets.value[index].thumbnailDataWithSize( - const ThumbnailSize(200, 200), - quality: 50, - ); - - return FutureBuilder( - future: thumbData, - builder: ((context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return Image.memory( - snapshot.data!, - width: 100, - height: 100, - fit: BoxFit.cover, - ); - } - - return const SizedBox( - width: 100, - height: 100, - child: ImmichLoadingIndicator(), - ); - }), + return ImmichThumbnail( + asset: assets.value[index], + width: 100, + height: 100, ); }, ), diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index ecfebd3cb7..0869e75e9f 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -3,21 +3,25 @@ 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/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.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/widgets/backup/album_info_card.dart'; import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @RoutePage() class BackupAlbumSelectionPage extends HookConsumerWidget { const BackupAlbumSelectionPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - // final availableAlbums = ref.watch(backupProvider).availableAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; final albums = ref.watch(backupProvider).availableAlbums; @@ -128,13 +132,12 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { album.name, style: TextStyle( fontSize: 12, - color: isDarkTheme ? Colors.black : immichBackgroundColor, + color: context.scaffoldBackgroundColor, fontWeight: FontWeight.bold, ), ), backgroundColor: Colors.red[300], - deleteIconColor: - isDarkTheme ? Colors.black : immichBackgroundColor, + deleteIconColor: context.scaffoldBackgroundColor, deleteIcon: const Icon( Icons.cancel_rounded, size: 15, @@ -146,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { }).toSet(); } - // buildSearchBar() { - // return Padding( - // padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0), - // child: TextFormField( - // onChanged: (searchValue) { - // // if (searchValue.isEmpty) { - // // albums = availableAlbums; - // // } else { - // // albums.value = availableAlbums - // // .where( - // // (album) => album.name - // // .toLowerCase() - // // .contains(searchValue.toLowerCase()), - // // ) - // // .toList(); - // // } - // }, - // decoration: InputDecoration( - // contentPadding: const EdgeInsets.symmetric( - // horizontal: 8.0, - // vertical: 8.0, - // ), - // hintText: "Search", - // hintStyle: TextStyle( - // color: isDarkTheme ? Colors.white : Colors.grey, - // fontSize: 14.0, - // ), - // prefixIcon: const Icon( - // Icons.search, - // color: Colors.grey, - // ), - // border: OutlineInputBorder( - // borderRadius: BorderRadius.circular(10), - // borderSide: BorderSide.none, - // ), - // filled: true, - // fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200], - // ), - // ), - // ); - // } + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } return Scaffold( appBar: AppBar( @@ -228,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), ), + SettingsSwitchListTile( + valueNotifier: enableSyncUploadAlbum, + title: "sync_albums".tr(), + subtitle: "sync_upload_album_setting_subtitle".tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + ListTile( title: Text( "backup_album_selection_page_albums_device".tr( diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 89384cf97a..d8baecf808 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -1,12 +1,15 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -17,6 +20,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class BackupControllerPage extends HookConsumerWidget { @@ -27,6 +31,9 @@ class BackupControllerPage extends HookConsumerWidget { BackUpState backupState = ref.watch(backupProvider); final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty; final didGetBackupInfo = useState(false); + final isScreenDarkened = useState(false); + final darkenScreenTimer = useRef(null); + bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; bool shouldBackup = backupState.allUniqueAssets.length - @@ -36,6 +43,25 @@ class BackupControllerPage extends HookConsumerWidget { ? false : true; + void startScreenDarkenTimer() { + darkenScreenTimer.value = Timer(const Duration(seconds: 30), () { + isScreenDarkened.value = true; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + }); + } + + void stopScreenDarkenTimer() { + darkenScreenTimer.value?.cancel(); + isScreenDarkened.value = false; + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: [ + SystemUiOverlay.top, + SystemUiOverlay.bottom, + ], + ); + } + useEffect( () { // Update the background settings information just to make sure we @@ -48,7 +74,12 @@ class BackupControllerPage extends HookConsumerWidget { ref .watch(websocketProvider.notifier) .stopListenToEvent('on_upload_success'); - return null; + + return () { + WakelockPlus.disable(); + darkenScreenTimer.value?.cancel(); + isScreenDarkened.value = false; + }; }, [], ); @@ -65,6 +96,21 @@ class BackupControllerPage extends HookConsumerWidget { [backupState.backupProgress], ); + useEffect( + () { + if (backupState.backupProgress == BackUpProgressEnum.inProgress) { + startScreenDarkenTimer(); + WakelockPlus.enable(); + } else { + stopScreenDarkenTimer(); + WakelockPlus.disable(); + } + + return null; + }, + [backupState.backupProgress], + ); + Widget buildSelectedAlbumName() { var text = "backup_controller_page_backup_selected".tr(); var albums = ref.watch(backupProvider).selectedBackupAlbums; @@ -130,9 +176,7 @@ class BackupControllerPage extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 56, 56, 56) - : Colors.black12, + color: context.colorScheme.outlineVariant, width: 1, ), ), @@ -151,7 +195,9 @@ class BackupControllerPage extends HookConsumerWidget { children: [ Text( "backup_controller_page_to_backup", - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ).tr(), buildSelectedAlbumName(), buildExcludedAlbumName(), @@ -166,7 +212,7 @@ class BackupControllerPage extends HookConsumerWidget { .read(backupProvider.notifier) .backupAlbumSelectionDone(); // waited until backup albums are stored in DB - ref.read(albumProvider.notifier).getDeviceAlbums(); + ref.read(albumProvider.notifier).refreshDeviceAlbums(); }, child: const Text( "backup_controller_page_select", @@ -251,72 +297,102 @@ class BackupControllerPage extends HookConsumerWidget { ); } - return Scaffold( - appBar: AppBar( - elevation: 0, - title: const Text( - "backup_controller_page_backup", - ).tr(), - leading: IconButton( - onPressed: () { - ref.watch(websocketProvider.notifier).listenUploadEvent(); - context.maybePop(true); - }, - splashRadius: 24, - icon: const Icon( - Icons.arrow_back_ios_rounded, - ), - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: IconButton( - onPressed: () => context.pushRoute(const BackupOptionsRoute()), + return GestureDetector( + onTap: () { + if (isScreenDarkened.value) { + stopScreenDarkenTimer(); + } + if (backupState.backupProgress == BackUpProgressEnum.inProgress) { + startScreenDarkenTimer(); + } + }, + child: AnimatedOpacity( + opacity: isScreenDarkened.value ? 0.1 : 1.0, + duration: const Duration(seconds: 1), + child: Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text( + "backup_controller_page_backup", + ).tr(), + leading: IconButton( + onPressed: () { + ref.watch(websocketProvider.notifier).listenUploadEvent(); + context.maybePop(true); + }, splashRadius: 24, icon: const Icon( - Icons.settings_outlined, + Icons.arrow_back_ios_rounded, ), ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () => + context.pushRoute(const BackupOptionsRoute()), + splashRadius: 24, + icon: const Icon( + Icons.settings_outlined, + ), + ), + ), + ], + ), + body: Stack( + children: [ + Padding( + padding: + const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), + child: ListView( + // crossAxisAlignment: CrossAxisAlignment.start, + children: hasAnyAlbum + ? [ + buildFolderSelectionTile(), + BackupInfoCard( + title: "backup_controller_page_total".tr(), + subtitle: "backup_controller_page_total_sub".tr(), + info: ref + .watch(backupProvider) + .availableAlbums + .isEmpty + ? "..." + : "${backupState.allUniqueAssets.length}", + ), + BackupInfoCard( + title: "backup_controller_page_backup".tr(), + subtitle: "backup_controller_page_backup_sub".tr(), + info: ref + .watch(backupProvider) + .availableAlbums + .isEmpty + ? "..." + : "${backupState.selectedAlbumsBackupAssetsIds.length}", + ), + BackupInfoCard( + title: "backup_controller_page_remainder".tr(), + subtitle: + "backup_controller_page_remainder_sub".tr(), + info: ref + .watch(backupProvider) + .availableAlbums + .isEmpty + ? "..." + : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", + ), + const Divider(), + const CurrentUploadingAssetInfoBox(), + if (!hasExclusiveAccess) buildBackgroundBackupInfo(), + buildBackupButton(), + ] + : [ + buildFolderSelectionTile(), + if (!didGetBackupInfo.value) buildLoadingIndicator(), + ], + ), + ), + ], ), - ], - ), - body: Padding( - padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), - child: ListView( - // crossAxisAlignment: CrossAxisAlignment.start, - children: hasAnyAlbum - ? [ - buildFolderSelectionTile(), - BackupInfoCard( - title: "backup_controller_page_total".tr(), - subtitle: "backup_controller_page_total_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${backupState.allUniqueAssets.length}", - ), - BackupInfoCard( - title: "backup_controller_page_backup".tr(), - subtitle: "backup_controller_page_backup_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${backupState.selectedAlbumsBackupAssetsIds.length}", - ), - BackupInfoCard( - title: "backup_controller_page_remainder".tr(), - subtitle: "backup_controller_page_remainder_sub".tr(), - info: ref.watch(backupProvider).availableAlbums.isEmpty - ? "..." - : "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}", - ), - const Divider(), - const CurrentUploadingAssetInfoBox(), - if (!hasExclusiveAccess) buildBackgroundBackupInfo(), - buildBackupButton(), - ] - : [ - buildFolderSelectionTile(), - if (!didGetBackupInfo.value) buildLoadingIndicator(), - ], ), ), ); diff --git a/mobile/lib/pages/backup/failed_backup_status.page.dart b/mobile/lib/pages/backup/failed_backup_status.page.dart index 1c6d3a7aad..551555d75e 100644 --- a/mobile/lib/pages/backup/failed_backup_status.page.dart +++ b/mobile/lib/pages/backup/failed_backup_status.page.dart @@ -3,9 +3,8 @@ 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/providers/backup/error_backup_list.provider.dart'; +import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:intl/intl.dart'; -import 'package:photo_manager/photo_manager.dart'; -import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; @RoutePage() class FailedBackupStatusPage extends HookConsumerWidget { @@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: Image( fit: BoxFit.cover, - image: AssetEntityImageProvider( - errorAsset.asset, - isOriginal: false, - thumbnailSize: const ThumbnailSize.square(512), - thumbnailFormat: ThumbnailFormat.jpeg, + image: ImmichLocalThumbnailProvider( + asset: errorAsset.asset, + height: 512, + width: 512, ), ), ), diff --git a/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart b/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart index 5e253a7b58..02026b828d 100644 --- a/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -@RoutePage?>() +@RoutePage() class AlbumAdditionalSharedUserSelectionPage extends HookConsumerWidget { final Album album; diff --git a/mobile/lib/pages/common/album_asset_selection.page.dart b/mobile/lib/pages/common/album_asset_selection.page.dart index b1281a2486..18ceb3e144 100644 --- a/mobile/lib/pages/common/album_asset_selection.page.dart +++ b/mobile/lib/pages/common/album_asset_selection.page.dart @@ -12,7 +12,7 @@ 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() +@RoutePage() class AlbumAssetSelectionPage extends HookConsumerWidget { const AlbumAssetSelectionPage({ super.key, diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 1cc24af09c..93e4c180fe 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -5,7 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -44,11 +45,11 @@ class AlbumOptionsPage extends HookConsumerWidget { try { final isSuccess = - await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.read(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { context.navigateTo( - const TabControllerRoute(children: [SharingRoute()]), + TabControllerRoute(children: [AlbumsRoute()]), ); } else { showErrorMessage(); @@ -64,9 +65,7 @@ class AlbumOptionsPage extends HookConsumerWidget { isProcessing.value = true; try { - await ref - .read(sharedAlbumProvider.notifier) - .removeUserFromAlbum(album, user); + await ref.read(albumProvider.notifier).removeUser(album, user); album.sharedUsers.remove(user); sharedUsers.value = album.sharedUsers.toList(); } catch (error) { @@ -102,7 +101,7 @@ class AlbumOptionsPage extends HookConsumerWidget { } showModalBottomSheet( - backgroundColor: context.scaffoldBackgroundColor, + backgroundColor: context.colorScheme.surfaceContainer, isScrollControlled: false, context: context, builder: (context) { @@ -131,7 +130,7 @@ class AlbumOptionsPage extends HookConsumerWidget { ), subtitle: Text( album.owner.value?.email ?? "", - style: TextStyle(color: Colors.grey[600]), + style: TextStyle(color: context.colorScheme.onSurfaceSecondary), ), trailing: Text( "shared_album_section_people_owner_label", @@ -160,7 +159,9 @@ class AlbumOptionsPage extends HookConsumerWidget { ), subtitle: Text( user.email, - style: TextStyle(color: Colors.grey[600]), + style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, + ), ), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) @@ -197,8 +198,8 @@ class AlbumOptionsPage extends HookConsumerWidget { onChanged: (bool value) async { activityEnabled.value = value; if (await ref - .read(sharedAlbumProvider.notifier) - .setActivityEnabled(album, value)) { + .read(albumProvider.notifier) + .setActivitystatus(album, value)) { album.activityEnabled = value; } }, @@ -214,7 +215,7 @@ class AlbumOptionsPage extends HookConsumerWidget { subtitle: Text( "shared_album_activity_setting_subtitle", style: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color?.withAlpha(175), + color: context.colorScheme.onSurfaceSecondary, ), ).tr(), ), diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/common/album_shared_user_selection.page.dart index d8cf4ecd27..9dadef1a76 100644 --- a/mobile/lib/pages/common/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_shared_user_selection.page.dart @@ -5,15 +5,15 @@ 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/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_title.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; -@RoutePage>() +@RoutePage() class AlbumSharedUserSelectionPage extends HookConsumerWidget { const AlbumSharedUserSelectionPage({super.key, required this.assets}); @@ -25,20 +25,15 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { final suggestedShareUsers = ref.watch(otherUsersProvider); createSharedAlbum() async { - var newAlbum = - await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum( - ref.watch(albumTitleProvider), - assets, - sharedUsersList.value, - ); + var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( + ref.watch(albumTitleProvider), + assets, + ); if (newAlbum != null) { - await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - // ref.watch(assetSelectionProvider.notifier).removeAll(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); context.maybePop(true); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } ScaffoldMessenger( diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index e1e0419d52..b977128cfa 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -11,10 +11,8 @@ 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/current_album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/widgets/album/album_action_outlined_button.dart'; +import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; @@ -50,9 +48,7 @@ class AlbumViewerPage extends HookConsumerWidget { Future onRemoveFromAlbumPressed(Iterable assets) async { final a = album.valueOrNull; final bool isSuccess = a != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(a, assets); + await ref.read(albumProvider.notifier).removeAsset(a, assets); if (!isSuccess) { ImmichToast.show( @@ -81,9 +77,9 @@ class AlbumViewerPage extends HookConsumerWidget { // Check if there is new assets add isProcessing.value = true; - await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( - returnPayload.selectedAssets, + await ref.watch(albumProvider.notifier).addAssets( albumInfo, + returnPayload.selectedAssets, ); isProcessing.value = false; @@ -98,9 +94,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (sharedUserIds != null) { isProcessing.value = true; - await ref - .watch(albumServiceProvider) - .addAdditionalUserToAlbum(sharedUserIds, album); + await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds); isProcessing.value = false; } @@ -114,13 +108,13 @@ class AlbumViewerPage extends HookConsumerWidget { child: ListView( scrollDirection: Axis.horizontal, children: [ - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.add_photo_alternate_outlined, onPressed: () => onAddPhotosPressed(album), labelText: "share_add_photos".tr(), ), if (userId == album.ownerId) - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.person_add_alt_rounded, onPressed: () => onAddUsersPressed(album), labelText: "album_viewer_page_share_add_users".tr(), @@ -184,27 +178,29 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget buildSharedUserIconsRow(Album album) { - return GestureDetector( - onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), - child: SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemBuilder: ((context, index) { - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar( - user: album.sharedUsers.toList()[index], - radius: 18, - size: 36, + return album.sharedUsers.isNotEmpty + ? GestureDetector( + onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.only(left: 16), + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: UserCircleAvatar( + user: album.sharedUsers.toList()[index], + radius: 18, + size: 36, + ), + ); + }), + itemCount: album.sharedUsers.length, ), - ); - }), - itemCount: album.sharedUsers.length, - ), - ), - ); + ), + ) + : const SizedBox.shrink(); } Widget buildHeader(Album album) { @@ -214,7 +210,7 @@ class AlbumViewerPage extends HookConsumerWidget { children: [ buildTitle(album), if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), - if (album.shared) buildSharedUserIconsRow(album), + buildSharedUserIconsRow(album), ], ); } @@ -231,17 +227,17 @@ class AlbumViewerPage extends HookConsumerWidget { body: Stack( children: [ album.widgetWhen( - onData: (data) => MultiselectGrid( + onData: (albumInfo) => MultiselectGrid( renderListProvider: albumRenderlistProvider(albumId), topWidget: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildHeader(data), - if (data.isRemote) buildControlButton(data), + buildHeader(albumInfo), + if (albumInfo.isRemote) buildControlButton(albumInfo), ], ), onRemoveFromAlbum: onRemoveFromAlbumPressed, - editEnabled: data.ownerId == userId, + editEnabled: albumInfo.ownerId == userId, ), ), AnimatedPositioned( diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 8066835d84..fd718ee37d 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/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'; @@ -18,7 +19,6 @@ class AppLogPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final immichLogger = ImmichLogger(); final logMessages = useState(immichLogger.messages); - final isDarkTheme = context.isDarkTheme; Widget colorStatusIndicator(Color color) { return Column( @@ -55,13 +55,9 @@ class AppLogPage extends HookConsumerWidget { case LogLevel.INFO: return Colors.transparent; case LogLevel.SEVERE: - return isDarkTheme - ? Colors.redAccent.withOpacity(0.25) - : Colors.redAccent.withOpacity(0.075); + return Colors.redAccent.withOpacity(0.25); case LogLevel.WARNING: - return isDarkTheme - ? Colors.orangeAccent.withOpacity(0.25) - : Colors.orangeAccent.withOpacity(0.075); + return Colors.orangeAccent.withOpacity(0.25); default: return context.primaryColor.withOpacity(0.1); } @@ -120,10 +116,7 @@ class AppLogPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) { - return Divider( - height: 0, - color: isDarkTheme ? Colors.white70 : Colors.grey[600], - ); + return const Divider(height: 0); }, itemCount: logMessages.value.length, itemBuilder: (context, index) { @@ -141,8 +134,9 @@ class AppLogPage extends HookConsumerWidget { minLeadingWidth: 10, title: Text( truncateLogMessage(logMessage.message, 4), - style: const TextStyle( + style: TextStyle( fontSize: 14.0, + color: context.colorScheme.onSurface, fontFamily: "Inconsolata", ), ), @@ -150,7 +144,7 @@ class AppLogPage extends HookConsumerWidget { "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", style: TextStyle( fontSize: 12.0, - color: Colors.grey[600], + color: context.colorScheme.onSurfaceSecondary, ), ), leading: buildLeadingIcon(logMessage.level), diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index 61f510c0de..1b9af6cfcf 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -13,8 +13,6 @@ class AppLogDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var isDarkTheme = context.isDarkTheme; - buildTextWithCopyButton(String header, String text) { return Padding( padding: const EdgeInsets.all(8.0), @@ -61,7 +59,7 @@ class AppLogDetailPage extends HookConsumerWidget { ), Container( decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(15.0), ), child: Padding( @@ -100,7 +98,7 @@ class AppLogDetailPage extends HookConsumerWidget { ), Container( decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[900] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(15.0), ), child: Padding( diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 053057425e..55261f6d55 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -10,20 +10,18 @@ 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_outlined_button.dart'; +import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_title_text_field.dart'; import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart'; @RoutePage() // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - final bool isSharedAlbum; - final List? initialAssets; + final List? assets; const CreateAlbumPage({ super.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); @override @@ -34,24 +32,16 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {}, + assets != null ? Set.from(assets!) : const {}, ); - showSelectUserPage() async { - final bool? ok = await context.pushRoute( - AlbumSharedUserSelectionRoute(assets: selectedAssets.value), - ); - if (ok == true) { - selectedAssets.value = {}; - } - } - void onBackgroundTapped() { albumTitleTextFieldFocusNode.unfocus(); isAlbumTitleTextFieldFocus.value = false; if (albumTitleController.text.isEmpty) { albumTitleController.text = 'create_album_page_untitled'.tr(); + isAlbumTitleEmpty.value = false; ref .watch(albumTitleProvider.notifier) .setAlbumTitle('create_album_page_untitled'.tr()); @@ -109,20 +99,16 @@ class CreateAlbumPage extends HookConsumerWidget { if (selectedAssets.value.isEmpty) { return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 16, left: 18, right: 18), - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: FilledButton.icon( + style: FilledButton.styleFrom( alignment: Alignment.centerLeft, padding: - const EdgeInsets.symmetric(vertical: 22, horizontal: 16), - side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 63, 63, 63) - : const Color.fromARGB(255, 129, 129, 129), - ), + const EdgeInsets.symmetric(vertical: 24, horizontal: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + borderRadius: BorderRadius.circular(10), ), + backgroundColor: context.colorScheme.surfaceContainerHigh, ), onPressed: onSelectPhotosButtonPressed, icon: Icon( @@ -134,6 +120,7 @@ class CreateAlbumPage extends HookConsumerWidget { child: Text( 'create_shared_album_page_share_select_photos', style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, color: context.primaryColor, ), ).tr(), @@ -150,11 +137,11 @@ class CreateAlbumPage extends HookConsumerWidget { return Padding( padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16), child: SizedBox( - height: 30, + height: 42, child: ListView( scrollDirection: Axis.horizontal, children: [ - AlbumActionOutlinedButton( + AlbumActionFilledButton( iconData: Icons.add_photo_alternate_outlined, onPressed: onSelectPhotosButtonPressed, labelText: "share_add_photos".tr(), @@ -194,13 +181,14 @@ class CreateAlbumPage extends HookConsumerWidget { } createNonSharedAlbum() async { + onBackgroundTapped(); var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( ref.watch(albumTitleProvider), selectedAssets.value, ); if (newAlbum != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectedAssets.value = {}; ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); @@ -224,35 +212,20 @@ class CreateAlbumPage extends HookConsumerWidget { 'share_create_album', ).tr(), actions: [ - if (isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? showSelectUserPage - : null, - child: Text( - 'create_shared_album_page_share'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: albumTitleController.text.isEmpty - ? context.themeData.disabledColor - : context.primaryColor, - ), - ), - ), - if (!isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty && - selectedAssets.value.isNotEmpty - ? createNonSharedAlbum - : null, - child: Text( - 'create_shared_album_page_create'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: context.primaryColor, - ), + TextButton( + onPressed: albumTitleController.text.isNotEmpty + ? createNonSharedAlbum + : null, + child: Text( + 'create_shared_album_page_create'.tr(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: albumTitleController.text.isNotEmpty + ? context.primaryColor + : context.themeData.disabledColor, ), ), + ), ], ), body: GestureDetector( @@ -266,7 +239,7 @@ class CreateAlbumPage extends HookConsumerWidget { pinned: true, floating: false, bottom: PreferredSize( - preferredSize: const Size.fromHeight(66.0), + preferredSize: const Size.fromHeight(96.0), child: Column( children: [ buildTitleInputField(), diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000..95cefd742a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + 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(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 704ee2829f..57c75ca84d 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,8 +8,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -22,7 +24,7 @@ import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart'; -import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart'; import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; @@ -30,7 +32,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; -import 'package:isar/isar.dart'; @RoutePage() // ignore: must_be_immutable @@ -55,8 +56,6 @@ class GalleryViewerPage extends HookConsumerWidget { final settings = ref.watch(appSettingsServiceProvider); final loadAsset = renderList.loadAsset; final totalAssets = useState(renderList.totalAssets); - final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); - final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue); final isZoomed = useState(false); final isPlayingVideo = useState(false); @@ -70,12 +69,12 @@ class GalleryViewerPage extends HookConsumerWidget { }); final stackIndex = useState(-1); - final stack = showStack && currentAsset.stackChildrenCount > 0 + final stack = showStack && currentAsset.stackCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == Isar.autoIncrement; + final isFromDto = currentAsset.id == noDbId; Asset asset = stackIndex.value == -1 ? currentAsset @@ -97,10 +96,6 @@ class GalleryViewerPage extends HookConsumerWidget { useEffect( () { - isLoadPreview.value = - settings.getSetting(AppSettingsEnum.loadPreview); - isLoadOriginal.value = - settings.getSetting(AppSettingsEnum.loadOriginal); shouldLoopVideo.value = settings.getSetting(AppSettingsEnum.loopVideo); return null; @@ -152,7 +147,7 @@ class GalleryViewerPage extends HookConsumerWidget { .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.advancedTroubleshooting) ? AdvancedBottomSheet(assetDetail: asset) - : ExifBottomSheet(asset: asset), + : DetailPanel(asset: asset), ), ); }, @@ -270,7 +265,7 @@ class GalleryViewerPage extends HookConsumerWidget { return PopScope( // Change immersive mode back to normal "edgeToEdge" mode - onPopInvoked: (_) => + onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge), child: Scaffold( backgroundColor: Colors.black, @@ -324,6 +319,7 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (context, index) { final a = index == currentIndex.value ? asset : loadAsset(index); + final ImageProvider provider = ImmichImage.imageProvider(asset: a); @@ -426,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index e2a816bce1..7f6ee3e4e2 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -74,7 +74,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvoked: (_) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart new file mode 100644 index 0000000000..8213ca423f --- /dev/null +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class LargeLeadingTile extends StatelessWidget { + const LargeLeadingTile({ + super.key, + required this.leading, + required this.onTap, + required this.title, + this.subtitle, + this.leadingPadding = const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16.0, + ), + this.borderRadius = 20.0, + }); + + final Widget leading; + final VoidCallback onTap; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry leadingPadding; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: leadingPadding, + child: leading, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.6, + child: title, + ), + subtitle ?? const SizedBox.shrink(), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 486eeba4cd..117b0aedc0 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -49,10 +49,6 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: AppBar( centerTitle: false, - bottom: const PreferredSize( - preferredSize: Size.fromHeight(1), - child: Divider(height: 1), - ), title: const Text('setting_pages_app_bar_settings').tr(), ), body: context.isMobile ? _MobileLayout() : _TabletLayout(), @@ -67,13 +63,18 @@ class _MobileLayout extends StatelessWidget { children: SettingSection.values .map( (s) => ListTile( - title: Text( - s.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ).tr(), + contentPadding: + const EdgeInsets.symmetric(vertical: 2.0, horizontal: 16.0), leading: Icon(s.icon), + title: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + s.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), onTap: () => context.pushRoute(SettingsSubRoute(section: s)), ), ) @@ -102,7 +103,7 @@ class _TabletLayout extends HookWidget { leading: Icon(s.icon), selected: s.index == selectedSection.value.index, selectedColor: context.primaryColor, - selectedTileColor: context.primaryColor.withAlpha(50), + selectedTileColor: context.themeData.highlightColor, onTap: () => selectedSection.value = s, ), ), diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index a48e9e92be..1ba9650056 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -3,8 +3,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -16,10 +18,11 @@ class TabControllerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final refreshing = ref.watch(assetProvider); + final isRefreshingAssets = ref.watch(assetProvider); + final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider); - Widget buildIcon(Widget icon) { - if (!refreshing) return icon; + Widget buildIcon({required Widget icon, required bool isProcessing}) { + if (!isProcessing) return icon; return Stack( alignment: Alignment.center, clipBehavior: Clip.none, @@ -42,75 +45,27 @@ class TabControllerPage extends HookConsumerWidget { ); } - navigationRail(TabsRouter tabsRouter) { - return NavigationRail( - labelType: NavigationRailLabelType.all, - selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) { - // Selected Photos while it is active - if (tabsRouter.activeIndex == 0 && index == 0) { - // Scroll to top - scrollToTopNotifierProvider.scrollToTop(); - } + onNavigationSelected(TabsRouter router, int index) { + // On Photos page menu tapped + if (router.activeIndex == 0 && index == 0) { + scrollToTopNotifierProvider.scrollToTop(); + } - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - tabsRouter.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - }, - selectedIconTheme: IconThemeData( - color: context.primaryColor, - ), - selectedLabelTextStyle: TextStyle( - color: context.primaryColor, - ), - useIndicator: false, - destinations: [ - NavigationRailDestination( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 4, - left: 4, - right: 4, - bottom: 4, - ), - icon: const Icon(Icons.photo_library_outlined), - selectedIcon: const Icon(Icons.photo_library), - label: const Text('tab_controller_nav_photos').tr(), - ), - NavigationRailDestination( - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.search_rounded), - selectedIcon: const Icon(Icons.search), - label: const Text('tab_controller_nav_search').tr(), - ), - NavigationRailDestination( - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.share_rounded), - selectedIcon: const Icon(Icons.share), - label: const Text('tab_controller_nav_sharing').tr(), - ), - NavigationRailDestination( - padding: const EdgeInsets.all(4), - icon: const Icon(Icons.photo_album_outlined), - selectedIcon: const Icon(Icons.photo_album), - label: const Text('tab_controller_nav_library').tr(), - ), - ], - ); + // On Search page tapped + if (router.activeIndex == 1 && index == 1) { + ref.read(searchInputFocusProvider).requestFocus(); + } + + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + router.setActiveIndex(index); + ref.read(tabProvider.notifier).state = TabEnum.values[index]; } bottomNavigationBar(TabsRouter tabsRouter) { return NavigationBar( selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) { - if (tabsRouter.activeIndex == 0 && index == 0) { - // Scroll to top - scrollToTopNotifierProvider.scrollToTop(); - } - - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - tabsRouter.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - }, + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), destinations: [ NavigationDestination( label: 'tab_controller_nav_photos'.tr(), @@ -118,7 +73,8 @@ class TabControllerPage extends HookConsumerWidget { Icons.photo_library_outlined, ), selectedIcon: buildIcon( - Icon( + isProcessing: isRefreshingAssets, + icon: Icon( Icons.photo_library, color: context.primaryColor, ), @@ -135,38 +91,42 @@ class TabControllerPage extends HookConsumerWidget { ), ), NavigationDestination( - label: 'tab_controller_nav_sharing'.tr(), - icon: const Icon( - Icons.group_outlined, - ), - selectedIcon: Icon( - Icons.group, - color: context.primaryColor, - ), - ), - NavigationDestination( - label: 'tab_controller_nav_library'.tr(), + label: 'albums'.tr(), icon: const Icon( Icons.photo_album_outlined, ), selectedIcon: buildIcon( - Icon( + 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, + ), + ), + ), ], ); } final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( - routes: const [ - PhotosRoute(), + routes: [ + const PhotosRoute(), SearchRoute(), - SharingRoute(), - LibraryRoute(), + const AlbumsRoute(), + const LibraryRoute(), ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition( @@ -177,35 +137,15 @@ class TabControllerPage extends HookConsumerWidget { final tabsRouter = AutoTabsRouter.of(context); return PopScope( canPop: tabsRouter.activeIndex == 0, - onPopInvoked: (didPop) => + onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null, - child: LayoutBuilder( - builder: (context, constraints) { - const medium = 600; - final Widget? bottom; - final Widget body; - if (constraints.maxWidth < medium) { - // Normal phone width - bottom = bottomNavigationBar(tabsRouter); - body = child; - } else { - // Medium tablet width - bottom = null; - body = Row( - children: [ - navigationRail(tabsRouter), - Expanded(child: child), - ], - ); - } - return Scaffold( - body: HeroControllerScope( - controller: HeroController(), - child: body, - ), - bottomNavigationBar: multiselectEnabled ? null : bottom, - ); - }, + child: Scaffold( + body: HeroControllerScope( + controller: HeroController(), + child: child, + ), + bottomNavigationBar: + multiselectEnabled ? null : bottomNavigationBar(tabsRouter), ), ); }, diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart index 527411ec89..573f7277f2 100644 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ b/mobile/lib/pages/common/video_viewer.page.dart @@ -123,7 +123,7 @@ class VideoViewerPage extends HookConsumerWidget { final size = MediaQuery.sizeOf(context); return PopScope( - onPopInvoked: (pop) { + onPopInvokedWithResult: (didPop, _) { ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue.uninitialized(); }, diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 8a21cdf769..8bfb8c8bb9 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:crop_image/crop_image.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'edit.page.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; /// A widget for cropping an image. @@ -14,7 +17,8 @@ import 'package:auto_route/auto_route.dart'; @RoutePage() class CropImagePage extends HookWidget { final Image image; - const CropImagePage({super.key, required this.image}); + final Asset asset; + const CropImagePage({super.key, required this.image, required this.asset}); @override Widget build(BuildContext context) { @@ -23,123 +27,133 @@ class CropImagePage extends HookWidget { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).bottomAppBarTheme.color, - leading: CloseButton(color: Theme.of(context).iconTheme.color), + backgroundColor: context.scaffoldBackgroundColor, + title: Text("crop".tr()), + leading: CloseButton(color: context.primaryColor), actions: [ IconButton( icon: Icon( Icons.done_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () async { final croppedImage = await cropController.croppedImage(); - context.pushRoute(EditImageRoute(image: croppedImage)); + context.pushRoute( + EditImageRoute( + asset: asset, + image: croppedImage, + isEdited: true, + ), + ); }, ), ], ), - body: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(top: 20), - width: double.infinity, - height: constraints.maxHeight * 0.6, - child: CropImage( - controller: cropController, - image: image, - gridColor: Colors.white, - ), - ), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), + backgroundColor: context.scaffoldBackgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + children: [ + Container( + padding: const EdgeInsets.only(top: 20), + width: constraints.maxWidth * 0.9, + height: constraints.maxHeight * 0.6, + child: CropImage( + controller: cropController, + image: image, + gridColor: Colors.white, ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - bottom: 10, + ), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + bottom: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Icons.rotate_left, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateLeft(); + }, + ), + IconButton( + icon: Icon( + Icons.rotate_right, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateRight(); + }, + ), + ], + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: Icon( - Icons.rotate_left, - color: Theme.of(context).iconTheme.color, - ), - onPressed: () { - cropController.rotateLeft(); - }, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: null, + label: 'Free', ), - IconButton( - icon: Icon( - Icons.rotate_right, - color: Theme.of(context).iconTheme.color, - ), - onPressed: () { - cropController.rotateRight(); - }, + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', ), ], ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: null, - label: 'Free', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - ), - ], - ), - ], + ], + ), ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } @@ -188,7 +202,7 @@ class _AspectRatioButton extends StatelessWidget { icon: Icon( iconData, color: aspectRatio.value == ratio - ? Colors.indigo + ? context.primaryColor : Theme.of(context).iconTheme.color, ), onPressed: () { @@ -197,7 +211,7 @@ class _AspectRatioButton extends StatelessWidget { cropController.aspectRatio = ratio; }, ), - Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text(label, style: context.textTheme.displayMedium), ], ); } diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index f7b431564b..650d2dc912 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:typed_data'; import 'dart:async'; import 'dart:ui'; @@ -7,12 +6,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/widgets/common/immich_image.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:path/path.dart' as p; /// A stateless widget that provides functionality for editing an image. /// @@ -24,18 +25,16 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; @immutable @RoutePage() class EditImagePage extends ConsumerWidget { - final Asset? asset; - final Image? image; + final Asset asset; + final Image image; + final bool isEdited; const EditImagePage({ super.key, - this.image, - this.asset, - }) : assert( - (image != null && asset == null) || (image == null && asset != null), - 'Must supply one of asset or image', - ); - + required this.asset, + required this.image, + required this.isEdited, + }); Future _imageToUint8List(Image image) async { final Completer completer = Completer(); image.image.resolve(const ImageConfiguration()).addListener( @@ -58,98 +57,143 @@ class EditImagePage extends ConsumerWidget { return completer.future; } + Future _saveEditedImage( + BuildContext context, + Asset asset, + Image image, + WidgetRef ref, + ) async { + try { + final Uint8List imageData = await _imageToUint8List(image); + await ref.read(fileMediaRepositoryProvider).saveImage( + imageData, + title: "${p.withoutExtension(asset.fileName)}_edited.jpg", + ); + await ref.read(albumProvider.notifier).refreshDeviceAlbums(); + Navigator.of(context).popUntil((route) => route.isFirst); + ImmichToast.show( + durationInSecond: 3, + context: context, + msg: 'Image Saved!', + gravity: ToastGravity.CENTER, + ); + } catch (e) { + ImmichToast.show( + durationInSecond: 6, + context: context, + msg: "error_saving_image".tr(args: [e.toString()]), + gravity: ToastGravity.CENTER, + ); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { - final ImageProvider provider = (asset != null) - ? ImmichImage.imageProvider(asset: asset!) - : (image != null) - ? image!.image - : throw Exception('Invalid image source type'); - - final Image imageWidget = (asset != null) - ? Image(image: ImmichImage.imageProvider(asset: asset!)) - : (image != null) - ? image! - : throw Exception('Invalid image source type'); - return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, + title: Text("edit_image_title".tr()), + backgroundColor: context.scaffoldBackgroundColor, leading: IconButton( icon: Icon( Icons.close_rounded, - color: Theme.of(context).iconTheme.color, + color: context.primaryColor, size: 24, ), onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst), ), actions: [ - if (image != null) - TextButton( - onPressed: () async { - try { - final Uint8List imageData = await _imageToUint8List(image!); - ImmichToast.show( - durationInSecond: 3, - context: context, - msg: 'Image Saved!', - gravity: ToastGravity.CENTER, - ); - - await PhotoManager.editor - .saveImage(imageData, title: "_edited.jpg"); - await ref.read(albumProvider.notifier).getDeviceAlbums(); - Navigator.of(context).popUntil((route) => route.isFirst); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: 'Error: ${e.toString()}', - gravity: ToastGravity.BOTTOM, - ); - } - }, - child: Text( - 'Save to gallery', - style: Theme.of(context).textTheme.displayMedium, + TextButton( + onPressed: isEdited + ? () => _saveEditedImage(context, asset, image, ref) + : null, + child: Text( + "save_to_gallery".tr(), + style: TextStyle( + color: isEdited ? context.primaryColor : Colors.grey, ), ), + ), ], ), - body: Column( - children: [ - Expanded( - child: Image(image: provider), + backgroundColor: context.scaffoldBackgroundColor, + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + maxWidth: MediaQuery.of(context).size.width * 0.9, ), - Container( - height: 80, - color: Theme.of(context).bottomAppBarTheme.color, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image( + image: image.image, + fit: BoxFit.contain, + ), + ), ), - ], + ), ), bottomNavigationBar: Container( - height: 80, - margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10), + height: 70, + margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10), decoration: BoxDecoration( - color: Theme.of(context).bottomAppBarTheme.color, + color: context.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(30), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon( - Platform.isAndroid - ? Icons.crop_rotate_rounded - : Icons.crop_rotate_rounded, - color: Theme.of(context).iconTheme.color, - ), - onPressed: () { - context.pushRoute(CropImageRoute(image: imageWidget)); - }, + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.crop_rotate_rounded, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + CropImageRoute(asset: asset, image: image), + ); + }, + ), + Text("crop".tr(), style: context.textTheme.displayMedium), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.filter, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + FilterImageRoute( + asset: asset, + image: image, + ), + ); + }, + ), + Text("filter".tr(), style: context.textTheme.displayMedium), + ], ), - Text('Crop', style: Theme.of(context).textTheme.displayMedium), ], ), ), diff --git a/mobile/lib/pages/editing/filter.page.dart b/mobile/lib/pages/editing/filter.page.dart new file mode 100644 index 0000000000..da8ba74891 --- /dev/null +++ b/mobile/lib/pages/editing/filter.page.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:ui' as ui; +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/entities/asset.entity.dart'; +import 'package:immich_mobile/constants/filters.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/routing/router.dart'; + +/// A widget for filtering an image. +/// This widget uses [HookWidget] to manage its lifecycle and state. It allows +/// users to add filters to an image and then navigate to the [EditImagePage] with the +/// final composition.' +@RoutePage() +class FilterImagePage extends HookWidget { + final Image image; + final Asset asset; + + const FilterImagePage({ + super.key, + required this.image, + required this.asset, + }); + + @override + Widget build(BuildContext context) { + final colorFilter = useState(filters[0]); + final selectedFilterIndex = useState(0); + + Future createFilteredImage( + ui.Image inputImage, + ColorFilter filter, + ) { + final completer = Completer(); + final size = + Size(inputImage.width.toDouble(), inputImage.height.toDouble()); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final paint = Paint()..colorFilter = filter; + canvas.drawImage(inputImage, Offset.zero, paint); + + recorder + .endRecording() + .toImage(size.width.round(), size.height.round()) + .then((image) { + completer.complete(image); + }); + + return completer.future; + } + + void applyFilter(ColorFilter filter, int index) { + colorFilter.value = filter; + selectedFilterIndex.value = index; + } + + Future applyFilterAndConvert(ColorFilter filter) async { + final completer = Completer(); + image.image.resolve(ImageConfiguration.empty).addListener( + ImageStreamListener((ImageInfo info, bool _) { + completer.complete(info.image); + }), + ); + final uiImage = await completer.future; + + final filteredUiImage = await createFilteredImage(uiImage, filter); + final byteData = + await filteredUiImage.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return Image.memory(pngBytes, fit: BoxFit.contain); + } + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("filter".tr()), + leading: CloseButton(color: context.primaryColor), + actions: [ + IconButton( + icon: Icon( + Icons.done_rounded, + color: context.primaryColor, + size: 24, + ), + onPressed: () async { + final filteredImage = + await applyFilterAndConvert(colorFilter.value); + context.pushRoute( + EditImageRoute( + asset: asset, + image: filteredImage, + isEdited: true, + ), + ); + }, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: Column( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Center( + child: ColorFiltered( + colorFilter: colorFilter.value, + child: image, + ), + ), + ), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filters.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _FilterButton( + image: image, + label: filterNames[index], + filter: filters[index], + isSelected: selectedFilterIndex.value == index, + onTap: () => applyFilter(filters[index], index), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _FilterButton extends StatelessWidget { + final Image image; + final String label; + final ColorFilter filter; + final bool isSelected; + final VoidCallback onTap; + + const _FilterButton({ + required this.image, + required this.label, + required this.filter, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + GestureDetector( + onTap: onTap, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: isSelected + ? Border.all(color: context.primaryColor, width: 3) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ColorFiltered( + colorFilter: filter, + child: FittedBox( + fit: BoxFit.cover, + child: image, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index 7462dc8f21..cc422f88c7 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.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/favorite_provider.dart'; +import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index be98440349..48d2c685ba 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -1,333 +1,429 @@ 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/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/partner.provider.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/user_avatar.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class LibraryPage extends HookConsumerWidget { +class LibraryPage extends ConsumerWidget { const LibraryPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final albums = ref.watch(albumProvider); - final isDarkTheme = context.isDarkTheme; - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - useEffect( - () { - ref.read(albumProvider.notifier).getAllAlbums(); - return null; - }, - [], - ); - - Widget buildSortButton() { - return PopupMenuButton( - position: PopupMenuPosition.over, - itemBuilder: (BuildContext context) { - return AlbumSortMode.values - .map>((option) { - final selected = albumSortOption == option; - return PopupMenuItem( - value: option, + return Scaffold( + appBar: ImmichAppBar(), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), child: Row( children: [ - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Icon( - Icons.check, - color: - selected ? context.primaryColor : Colors.transparent, - ), + ActionButton( + onPressed: () => context.pushRoute(const FavoritesRoute()), + icon: Icons.favorite_outline_rounded, + label: 'favorites'.tr(), ), - Text( - option.label.tr(), - style: TextStyle( - color: selected ? context.primaryColor : null, - fontSize: 14.0, - ), + const SizedBox(width: 8), + ActionButton( + onPressed: () => context.pushRoute(const ArchiveRoute()), + icon: Icons.archive_outlined, + label: 'archived'.tr(), ), ], ), - ); - }).toList(); - }, - onSelected: (AlbumSortMode value) { - final selected = albumSortOption == value; - // Switch direction - if (selected) { - ref - .read(albumSortOrderProvider.notifier) - .changeSortDirection(!albumSortIsReverse); - } else { - ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value); - } - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 5), - child: Icon( - albumSortIsReverse - ? Icons.arrow_downward_rounded - : Icons.arrow_upward_rounded, - size: 14, - color: context.primaryColor, - ), ), - Text( - albumSortOption.label.tr(), - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), + const SizedBox(height: 8), + Row( + children: [ + ActionButton( + onPressed: () => context.pushRoute(const SharedLinkRoute()), + icon: Icons.link_outlined, + label: 'shared_links'.tr(), + ), + SizedBox(width: trashEnabled ? 8 : 0), + trashEnabled + ? ActionButton( + onPressed: () => context.pushRoute(const TrashRoute()), + icon: Icons.delete_outline_rounded, + label: 'trash'.tr(), + ) + : const SizedBox.shrink(), + ], + ), + const SizedBox(height: 12), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + PeopleCollectionCard(), + PlacesCollectionCard(), + LocalAlbumsCollectionCard(), + ], + ), + const SizedBox(height: 12), + QuickAccessButtons(), + const SizedBox( + height: 32, ), ], ), - ); - } - - Widget buildCreateAlbumButton() { - return LayoutBuilder( - builder: (context, constraints) { - var cardSize = constraints.maxWidth; - - return GestureDetector( - onTap: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)), - child: Padding( - padding: - const EdgeInsets.only(bottom: 32), // Adjust padding to suit - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: cardSize, - height: cardSize, - decoration: BoxDecoration( - border: Border.all( - color: isDarkTheme - ? const Color.fromARGB(255, 53, 53, 53) - : const Color.fromARGB(255, 203, 203, 203), - ), - color: isDarkTheme ? Colors.grey[900] : Colors.grey[50], - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Center( - child: Icon( - Icons.add_rounded, - size: 28, - color: context.primaryColor, - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 16, - ), - child: Text( - 'library_page_new_album', - style: context.textTheme.labelLarge, - ).tr(), - ), - ], - ), - ), - ); - }, - ); - } - - Widget buildLibraryNavButton( - String label, - IconData icon, - Function() onClick, - ) { - return Expanded( - child: OutlinedButton.icon( - onPressed: onClick, - label: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - label, - style: TextStyle( - color: context.isDarkTheme - ? Colors.white - : Colors.black.withAlpha(200), - ), - ), - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - backgroundColor: isDarkTheme ? Colors.grey[900] : Colors.grey[50], - side: BorderSide( - color: isDarkTheme ? Colors.grey[800]! : Colors.grey[300]!, - ), - alignment: Alignment.centerLeft, - ), - icon: Icon( - icon, - color: context.primaryColor, - ), - ), - ); - } - - final remote = albums.where((a) => a.isRemote).toList(); - final sorted = albumSortOption.sortFn(remote, albumSortIsReverse); - final local = albums.where((a) => a.isLocal).toList(); - - Widget? shareTrashButton() { - return trashEnabled - ? InkWell( - onTap: () => context.pushRoute(const TrashRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.delete_rounded, - size: 25, - semanticLabel: 'profile_drawer_trash'.tr(), - ), - ) - : null; - } - - return Scaffold( - appBar: ImmichAppBar( - action: shareTrashButton(), ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - buildLibraryNavButton( - "library_page_favorites".tr(), Icons.favorite_border, () { - context.navigateTo(const FavoritesRoute()); - }), - const SizedBox(width: 12.0), - buildLibraryNavButton( - "library_page_archive".tr(), Icons.archive_outlined, () { - context.navigateTo(const ArchiveRoute()); - }), - ], - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_albums', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - buildSortButton(), - ], - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: sorted.length + 1, - (context, index) { - if (index == 0) { - return buildCreateAlbumButton(); - } + ); + } +} - return AlbumThumbnailCard( - album: sorted[index - 1], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: sorted[index - 1].id, - ), - ), - ); - }, +class QuickAccessButtons extends ConsumerWidget { + const QuickAccessButtons({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final partners = ref.watch(partnerSharedWithProvider); + + return Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0), + bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_device_albums', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ], - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: local.length, - (context, index) => AlbumThumbnailCard( - album: local[index], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: local[index].id, - ), - ), - ), + leading: const Icon( + Icons.group_outlined, + size: 26, + ), + title: Text( + 'partners'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, ), ), + onTap: () => context.pushRoute(const PartnerRoute()), ), + PartnerList(partners: partners), ], ), ); } } + +class PartnerList extends ConsumerWidget { + const PartnerList({super.key, required this.partners}); + + final List partners; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: partners.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final partner = partners[index]; + final isLastItem = index == partners.length - 1; + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(isLastItem ? 20 : 0), + bottomRight: Radius.circular(isLastItem ? 20 : 0), + ), + ), + contentPadding: const EdgeInsets.only( + left: 12.0, + right: 18.0, + ), + leading: userAvatar(context, partner, radius: 16), + title: Text( + "partner_list_user_photos", + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ).tr( + namedArgs: { + 'user': partner.name, + }, + ), + onTap: () => context.pushRoute( + (PartnerDetailRoute(partner: partner)), + ), + ); + }, + ); + } +} + +class PeopleCollectionCard extends ConsumerWidget { + const PeopleCollectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final widthFactor = isTablet ? 0.25 : 0.5; + final size = MediaQuery.of(context).size.width * widthFactor - 20.0; + + return GestureDetector( + onTap: () => context.pushRoute(const PeopleCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: people.widgetWhen( + onData: (people) { + return GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: people.take(4).map((person) { + return CircleAvatar( + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: ApiService.getRequestHeaders(), + ), + ); + }).toList(), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'people'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class LocalAlbumsCollectionCard extends HookConsumerWidget { + const LocalAlbumsCollectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final widthFactor = isTablet ? 0.25 : 0.5; + final size = MediaQuery.of(context).size.width * widthFactor - 20.0; + + return GestureDetector( + onTap: () => context.pushRoute( + const LocalAlbumsRoute(), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: albums.take(4).map((album) { + return AlbumThumbnailCard( + album: album, + showTitle: false, + ); + }).toList(), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'on_this_device'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class PlacesCollectionCard extends StatelessWidget { + const PlacesCollectionCard({super.key}); + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final widthFactor = isTablet ? 0.25 : 0.5; + final size = MediaQuery.of(context).size.width * widthFactor - 20.0; + + return GestureDetector( + onTap: () => context.pushRoute(const PlacesCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: context.colorScheme.secondaryContainer.withAlpha(100), + ), + child: IgnorePointer( + child: MapThumbnail( + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'places'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class ActionButton extends StatelessWidget { + final VoidCallback onPressed; + final IconData icon; + final String label; + + const ActionButton({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: FilledButton.icon( + onPressed: onPressed, + label: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + label, + style: TextStyle( + color: context.colorScheme.onSurface, + fontSize: 15, + ), + ), + ), + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + backgroundColor: context.colorScheme.surfaceContainerLow, + alignment: Alignment.centerLeft, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(25)), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + ), + ), + icon: Icon( + icon, + color: context.primaryColor, + ), + ), + ); + } +} diff --git a/mobile/lib/pages/library/local_albums.page.dart b/mobile/lib/pages/library/local_albums.page.dart new file mode 100644 index 0000000000..164ea3bad8 --- /dev/null +++ b/mobile/lib/pages/library/local_albums.page.dart @@ -0,0 +1,55 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class LocalAlbumsPage extends HookConsumerWidget { + const LocalAlbumsPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + return Scaffold( + appBar: AppBar( + title: Text('on_this_device'.tr()), + ), + body: ListView.builder( + padding: const EdgeInsets.all(18.0), + itemCount: albums.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: ImmichThumbnail( + asset: albums[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + title: Text( + albums[index].name, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text('${albums[index].assetCount} items'), + onTap: () => context + .pushRoute(AlbumViewerRoute(albumId: albums[index].id)), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart similarity index 93% rename from mobile/lib/pages/sharing/partner/partner.page.dart rename to mobile/lib/pages/library/partner/partner.page.dart index 8dd31023c7..1e9e801210 100644 --- a/mobile/lib/pages/sharing/partner/partner.page.dart +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -86,12 +86,10 @@ class PartnerPage extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: const Text( + child: Text( "partner_page_shared_to_title", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - fontWeight: FontWeight.bold, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), ), ).tr(), ), @@ -104,10 +102,7 @@ class PartnerPage extends HookConsumerWidget { leading: userAvatar(context, users[index]), title: Text( users[index].email, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: context.textTheme.bodyLarge, ), trailing: IconButton( icon: const Icon(Icons.person_remove), @@ -148,7 +143,7 @@ class PartnerPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text("partner_page_title").tr(), + title: const Text("partners").tr(), elevation: 0, centerTitle: false, actions: [ diff --git a/mobile/lib/pages/sharing/partner/partner_detail.page.dart b/mobile/lib/pages/library/partner/partner_detail.page.dart similarity index 59% rename from mobile/lib/pages/sharing/partner/partner_detail.page.dart rename to mobile/lib/pages/library/partner/partner_detail.page.dart index 8a2dd4b820..0874aacfa7 100644 --- a/mobile/lib/pages/sharing/partner/partner_detail.page.dart +++ b/mobile/lib/pages/library/partner/partner_detail.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -22,7 +23,11 @@ class PartnerDetailPage extends HookConsumerWidget { useEffect( () { - ref.read(assetProvider.notifier).getAllAsset(); + Future.microtask( + () async => { + await ref.read(assetProvider.notifier).getAllAsset(), + }, + ); return null; }, [], @@ -64,19 +69,47 @@ class PartnerDetailPage extends HookConsumerWidget { title: Text(partner.name), elevation: 0, centerTitle: false, - actions: [ - IconButton( - onPressed: toggleInTimeline, - icon: Icon( - inTimeline.value - ? Icons.collections - : Icons.collections_outlined, - ), - tooltip: "Show/hide photos on your main timeline", - ), - ], ), body: MultiselectGrid( + topWidget: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ListTile( + title: Text( + "Show in timeline", + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.primary, + ), + ), + subtitle: Text( + "Show photos and videos from this user in your timeline", + style: context.textTheme.bodyMedium, + ), + trailing: Switch( + value: inTimeline.value, + onChanged: (_) => toggleInTimeline(), + ), + ), + ), + ), + ), renderListProvider: assetsProvider(partner.isarId), onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), deleteEnabled: false, diff --git a/mobile/lib/pages/library/people/people_collection.page.dart b/mobile/lib/pages/library/people/people_collection.page.dart new file mode 100644 index 0000000000..ad78e27a41 --- /dev/null +++ b/mobile/lib/pages/library/people/people_collection.page.dart @@ -0,0 +1,114 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; + +@RoutePage() +class PeopleCollectionPage extends HookConsumerWidget { + const PeopleCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final headers = ApiService.getRequestHeaders(); + + showNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final isPortrait = + MediaQuery.of(context).orientation == Orientation.portrait; + + return Scaffold( + appBar: AppBar( + title: Text('people'.tr()), + ), + body: people.when( + data: (people) { + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isTablet ? 6 : 3, + childAspectRatio: 0.85, + mainAxisSpacing: isPortrait && isTablet ? 36 : 0, + ), + padding: const EdgeInsets.symmetric(vertical: 32), + itemCount: people.length, + itemBuilder: (context, index) { + final person = people[index]; + + return Column( + children: [ + GestureDetector( + onTap: () { + context.pushRoute( + PersonResultRoute( + personId: person.id, + personName: person.name, + ), + ); + }, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: isTablet ? 120 / 2 : 96 / 2, + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: headers, + ), + ), + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () => showNameEditModel(person.id, person.name), + child: person.name.isEmpty + ? Text( + 'add_a_name'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.primary, + ), + ) + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Text( + person.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + }, + ); + }, + error: (error, stack) => const Text("error"), + loading: () => const CircularProgressIndicator(), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart new file mode 100644 index 0000000000..3e4f9f6a1d --- /dev/null +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -0,0 +1,125 @@ +import 'package:auto_route/auto_route.dart'; +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/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/models/search/search_filter.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class PlacesCollectionPage extends HookConsumerWidget { + const PlacesCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final places = ref.watch(getAllPlacesProvider); + + return Scaffold( + appBar: AppBar( + title: Text('places'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + width: context.width, + child: MapThumbnail( + onTap: (_, __) => context.pushRoute(const MapRoute()), + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + places.when( + data: (places) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: places.length, + itemBuilder: (context, index) { + final place = places[index]; + + return PlaceTile(id: place.id, name: place.label); + }, + ); + }, + error: (error, stask) => const Text('Error getting places'), + loading: () => Center(child: const CircularProgressIndicator()), + ), + ], + ), + ); + } +} + +class PlaceTile extends StatelessWidget { + const PlaceTile({super.key, required this.id, required this.name}); + + final String id; + final String name; + + @override + Widget build(BuildContext context) { + final thumbnailUrl = + '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail'; + + void navigateToPlace() { + context.pushRoute( + SearchRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: name, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, + ), + ), + ); + } + + return LargeLeadingTile( + onTap: () => navigateToPlace(), + title: Text( + name, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + leading: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CachedNetworkImage( + width: 80, + height: 80, + fit: BoxFit.cover, + imageUrl: thumbnailUrl, + httpHeaders: ApiService.getRequestHeaders(), + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/shared_link/shared_link.page.dart b/mobile/lib/pages/library/shared_link/shared_link.page.dart similarity index 100% rename from mobile/lib/pages/sharing/shared_link/shared_link.page.dart rename to mobile/lib/pages/library/shared_link/shared_link.page.dart diff --git a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart similarity index 95% rename from mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart rename to mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 6223e110e1..7f1008c655 100644 --- a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -30,6 +30,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { const padding = 20.0; final themeData = context.themeData; + final colorScheme = context.colorScheme; final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionFocusNode = useFocusNode(); @@ -58,7 +59,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Text( existingLink!.title, style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, ), ), @@ -81,7 +82,7 @@ class SharedLinkEditPage extends HookConsumerWidget { child: Text( existingLink!.description ?? "--", style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, @@ -109,7 +110,7 @@ class SharedLinkEditPage extends HookConsumerWidget { labelText: 'shared_link_edit_description'.tr(), labelStyle: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), @@ -135,7 +136,7 @@ class SharedLinkEditPage extends HookConsumerWidget { labelText: 'shared_link_edit_password'.tr(), labelStyle: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), @@ -157,7 +158,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_show_meta", @@ -173,7 +174,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_allow_download", @@ -189,7 +190,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_allow_upload", @@ -205,7 +206,7 @@ class SharedLinkEditPage extends HookConsumerWidget { onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null, - activeColor: themeData.primaryColor, + activeColor: colorScheme.primary, dense: true, title: Text( "shared_link_edit_change_expiry", @@ -221,7 +222,7 @@ class SharedLinkEditPage extends HookConsumerWidget { "shared_link_edit_expire_after", style: TextStyle( fontWeight: FontWeight.bold, - color: themeData.primaryColor, + color: colorScheme.primary, ), ).tr(), enableSearch: false, @@ -233,14 +234,6 @@ class SharedLinkEditPage extends HookConsumerWidget { onSelected: (value) { expiryAfter.value = value!; }, - inputDecorationTheme: themeData.inputDecorationTheme.copyWith( - disabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), - ), - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.grey), - ), - ), dropdownMenuEntries: [ DropdownMenuEntry( value: 0, diff --git a/mobile/lib/pages/library/trash.page.dart b/mobile/lib/pages/library/trash.page.dart index 3bba2f2dfe..61c87e19a1 100644 --- a/mobile/lib/pages/library/trash.page.dart +++ b/mobile/lib/pages/library/trash.page.dart @@ -44,7 +44,7 @@ class TrashPage extends HookConsumerWidget { if (context.mounted) { ImmichToast.show( context: context, - msg: 'Emptied trash', + msg: 'trash_emptied'.tr(), gravity: ToastGravity.BOTTOM, ); } @@ -71,13 +71,11 @@ class TrashPage extends HookConsumerWidget { .removeAssets(selection.value); if (isRemoved) { - final assetOrAssets = - selection.value.length > 1 ? 'assets' : 'asset'; if (context.mounted) { ImmichToast.show( context: context, - msg: - '${selection.value.length} $assetOrAssets deleted permanently', + msg: 'assets_deleted_permanently' + .tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); } @@ -114,12 +112,11 @@ class TrashPage extends HookConsumerWidget { .read(trashProvider.notifier) .restoreAssets(selection.value); - final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; if (result && context.mounted) { ImmichToast.show( context: context, - msg: - '${selection.value.length} $assetOrAssets restored successfully', + msg: 'assets_restored_successfully' + .tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); } diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index 212145ed5a..8045ae649f 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/forms/login/login_form.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -28,7 +29,7 @@ class LoginPage extends HookConsumerWidget { ); return Scaffold( - body: const LoginForm(), + body: LoginForm(), bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.only(bottom: 16.0), @@ -39,8 +40,8 @@ class LoginPage extends HookConsumerWidget { children: [ Text( 'v${appVersion.value}', - style: const TextStyle( - color: Colors.grey, + style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontFamily: "Inconsolata", ), diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 3c5ff27296..14e5724155 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -7,7 +7,6 @@ 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/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_lane.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; @@ -33,8 +32,7 @@ class PhotosPage extends HookConsumerWidget { () { ref.read(websocketProvider.notifier).connect(); Future(() => ref.read(assetProvider.notifier).getAllAsset()); - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums()); ref.read(serverInfoProvider.notifier).getServerInfo(); return; }, diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index d226ea55a3..3be7e9b3e5 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; 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'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: MediaQuery.of(context).padding.bottom + 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( 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 db0c980c89..2fd1e1ee9e 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:immich_mobile/utils/map_utils.dart'; -@RoutePage() +@RoutePage() class MapLocationPickerPage extends HookConsumerWidget { final LatLng initialLatLng; diff --git a/mobile/lib/pages/search/person_result.page.dart b/mobile/lib/pages/search/person_result.page.dart index 55824b8db9..8627c65bcc 100644 --- a/mobile/lib/pages/search/person_result.page.dart +++ b/mobile/lib/pages/search/person_result.page.dart @@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget { Text( name.value, style: context.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, ), ], ), @@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget { headers: ApiService.getRequestHeaders(), ), ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: buildTitleBlock(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: buildTitleBlock(), + ), ), ], ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 2c578925c1..83220cff15 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -1,255 +1,782 @@ -import 'dart:math' as math; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package: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/search/search_curated_content.model.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/widgets/search/curated_people_row.dart'; -import 'package:immich_mobile/widgets/search/curated_places_row.dart'; -import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/widgets/search/search_row_section.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/scaffold_error_body.dart'; - -@RoutePage() -// ignore: must_be_immutable -class SearchPage extends HookConsumerWidget { - const SearchPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final places = ref.watch(getPreviewPlacesProvider); - final curatedPeople = ref.watch(getAllPeopleProvider); - final isMapEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - final double imageSize = math.min(context.width / 3, 150); - - TextStyle categoryTitleStyle = const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 15.0, - ); - - Color categoryIconColor = context.isDarkTheme ? Colors.white : Colors.black; - - showNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ); - } - - buildPeople() { - return curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()), - title: "search_page_people".tr(), - isEmpty: people.isEmpty, - child: CuratedPeopleRow( - padding: const EdgeInsets.symmetric(horizontal: 16), - content: people - .map((e) => SearchCuratedContent(label: e.name, id: e.id)) - .take(12) - .toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, - ), - ); - }, - ); - } - - buildPlaces() { - return places.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (data) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()), - title: "search_page_places".tr(), - isEmpty: !isMapEnabled && data.isEmpty, - child: CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: data, - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchInputRoute( - prefilter: SearchFilter( - people: {}, - location: SearchLocationFilter( - city: content.label, - ), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: AssetType.other, - ), - ), - ); - }, - ), - ); - }, - ); - } - - buildSearchButton() { - return GestureDetector( - onTap: () { - context.pushRoute(SearchInputRoute()); - }, - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide( - color: context.isDarkTheme - ? Colors.grey[800]! - : const Color.fromARGB(255, 225, 225, 225), - ), - ), - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - child: Row( - children: [ - Icon(Icons.search, color: context.primaryColor), - const SizedBox(width: 16.0), - Text( - "search_bar_hint", - style: context.textTheme.bodyLarge?.copyWith( - color: - context.isDarkTheme ? Colors.white70 : Colors.black54, - fontWeight: FontWeight.w400, - ), - ).tr(), - ], - ), - ), - ), - ); - } - - return Scaffold( - appBar: const ImmichAppBar(), - body: ListView( - children: [ - buildSearchButton(), - const SizedBox(height: 8.0), - buildPeople(), - const SizedBox(height: 8.0), - buildPlaces(), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'search_page_your_activity', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - leading: Icon( - Icons.favorite_border_rounded, - color: categoryIconColor, - ), - title: - Text('search_page_favorites', style: categoryTitleStyle).tr(), - onTap: () => context.pushRoute(const FavoritesRoute()), - ), - const CategoryDivider(), - ListTile( - leading: Icon( - Icons.schedule_outlined, - color: categoryIconColor, - ), - title: Text( - 'search_page_recently_added', - style: categoryTitleStyle, - ).tr(), - onTap: () => context.pushRoute(const RecentlyAddedRoute()), - ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - title: Text('search_page_videos', style: categoryTitleStyle).tr(), - leading: Icon( - Icons.play_circle_outline, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllVideosRoute()), - ), - const CategoryDivider(), - ListTile( - title: Text( - 'search_page_motion_photos', - style: categoryTitleStyle, - ).tr(), - leading: Icon( - Icons.motion_photos_on_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllMotionPhotosRoute()), - ), - ], - ), - ); - } -} - -class CategoryDivider extends StatelessWidget { - const CategoryDivider({super.key}); - - @override - Widget build(BuildContext context) { - return const Padding( - padding: EdgeInsets.only( - left: 56, - right: 16, - ), - child: Divider( - height: 0, - ), - ); - } -} +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/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/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; +import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; +import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; + +@RoutePage() +class SearchPage extends HookConsumerWidget { + const SearchPage({super.key, this.prefilter}); + + final SearchFilter? prefilter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isContextualSearch = useState(true); + final textSearchController = useTextEditingController(); + final filter = useState( + SearchFilter( + people: prefilter?.people ?? {}, + location: prefilter?.location ?? SearchLocationFilter(), + camera: prefilter?.camera ?? SearchCameraFilter(), + date: prefilter?.date ?? SearchDateFilter(), + display: prefilter?.display ?? + SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: prefilter?.mediaType ?? AssetType.other, + ), + ); + + final previousFilter = useState(filter.value); + + final peopleCurrentFilterWidget = useState(null); + final dateRangeCurrentFilterWidget = useState(null); + final cameraCurrentFilterWidget = useState(null); + final locationCurrentFilterWidget = useState(null); + final mediaTypeCurrentFilterWidget = useState(null); + final displayOptionCurrentFilterWidget = useState(null); + + final isSearching = useState(false); + + search() async { + if (prefilter == null && filter.value == previousFilter.value) return; + + isSearching.value = true; + ref.watch(paginatedSearchProvider.notifier).clear(); + await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + previousFilter.value = filter.value; + isSearching.value = false; + } + + loadMoreSearchResult() async { + isSearching.value = true; + await ref.watch(paginatedSearchProvider.notifier).search(filter.value); + isSearching.value = false; + } + + searchPrefilter() { + if (prefilter != null) { + Future.delayed( + Duration.zero, + () { + search(); + + if (prefilter!.location.city != null) { + locationCurrentFilterWidget.value = Text( + prefilter!.location.city!, + style: context.textTheme.labelLarge, + ); + } + }, + ); + } + } + + useEffect( + () { + Future.microtask( + () => ref.invalidate(paginatedSearchProvider), + ); + searchPrefilter(); + + return null; + }, + [], + ); + + showPeoplePicker() { + handleOnSelect(Set value) { + filter.value = filter.value.copyWith( + people: value, + ); + + peopleCurrentFilterWidget.value = Text( + value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + people: {}, + ); + + peopleCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_people_title'.tr(), + expanded: true, + onSearch: search, + onClear: handleClear, + child: PeoplePicker( + onSelect: handleOnSelect, + filter: filter.value.people, + ), + ), + ), + ); + } + + showLocationPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + location: SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ), + ); + + final locationText = []; + if (value['country'] != null) { + locationText.add(value['country']!); + } + + if (value['state'] != null) { + locationText.add(value['state']!); + } + + if (value['city'] != null) { + locationText.add(value['city']!); + } + + locationCurrentFilterWidget.value = Text( + locationText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + location: SearchLocationFilter(), + ); + + locationCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + child: FilterBottomSheetScaffold( + title: 'search_filter_location_title'.tr(), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LocationPicker( + onSelected: handleOnSelect, + filter: filter.value.location, + ), + ), + ), + ), + ), + ); + } + + showCameraPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter( + make: value['make'], + model: value['model'], + ), + ); + + cameraCurrentFilterWidget.value = Text( + '${value['make'] ?? ''} ${value['model'] ?? ''}', + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter(), + ); + + cameraCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + child: FilterBottomSheetScaffold( + title: 'search_filter_camera_title'.tr(), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CameraPicker( + onSelect: handleOnSelect, + filter: filter.value.camera, + ), + ), + ), + ); + } + + showDatePicker() async { + final firstDate = DateTime(1900); + final lastDate = DateTime.now(); + + final date = await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ), + helpText: 'search_filter_date_title'.tr(), + cancelText: 'action_common_cancel'.tr(), + confirmText: 'action_common_select'.tr(), + saveText: 'action_common_save'.tr(), + errorFormatText: 'invalid_date_format'.tr(), + errorInvalidText: 'invalid_date'.tr(), + fieldStartHintText: 'start_date'.tr(), + fieldEndHintText: 'end_date'.tr(), + initialEntryMode: DatePickerEntryMode.input, + ); + + if (date == null) { + filter.value = filter.value.copyWith( + date: SearchDateFilter(), + ); + + dateRangeCurrentFilterWidget.value = null; + search(); + return; + } + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + ), + ), + ); + + // If date range is less than 24 hours, set the end date to the end of the day + if (date.end.difference(date.start).inHours < 24) { + dateRangeCurrentFilterWidget.value = Text( + DateFormat.yMMMd().format(date.start.toLocal()), + style: context.textTheme.labelLarge, + ); + } else { + dateRangeCurrentFilterWidget.value = Text( + 'search_filter_date_interval'.tr( + namedArgs: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ), + style: context.textTheme.labelLarge, + ); + } + + search(); + } + + // MEDIA PICKER + showMediaTypePicker() { + handleOnSelected(AssetType assetType) { + filter.value = filter.value.copyWith( + mediaType: assetType, + ); + + mediaTypeCurrentFilterWidget.value = Text( + assetType == AssetType.image + ? 'search_filter_media_type_image'.tr() + : assetType == AssetType.video + ? 'search_filter_media_type_video'.tr() + : 'search_filter_media_type_all'.tr(), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + mediaType: AssetType.other, + ); + + mediaTypeCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_media_type_title'.tr(), + onSearch: search, + onClear: handleClear, + child: MediaTypePicker( + onSelect: handleOnSelected, + filter: filter.value.mediaType, + ), + ), + ); + } + + // DISPLAY OPTION + showDisplayOptionPicker() { + handleOnSelect(Map value) { + final filterText = []; + value.forEach((key, value) { + switch (key) { + case DisplayOption.notInAlbum: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isNotInAlbum: value, + ), + ); + if (value) { + filterText + .add('search_filter_display_option_not_in_album'.tr()); + } + break; + case DisplayOption.archive: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isArchive: value, + ), + ); + if (value) { + filterText.add('search_filter_display_option_archive'.tr()); + } + break; + case DisplayOption.favorite: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isFavorite: value, + ), + ); + if (value) { + filterText.add('search_filter_display_option_favorite'.tr()); + } + break; + } + }); + + if (filterText.isEmpty) { + displayOptionCurrentFilterWidget.value = null; + return; + } + + displayOptionCurrentFilterWidget.value = Text( + filterText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + ); + + displayOptionCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_display_options_title'.tr(), + onSearch: search, + onClear: handleClear, + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: filter.value.display, + ), + ), + ); + } + + handleTextSubmitted(String value) { + if (value.isEmpty) { + return; + } + + if (isContextualSearch.value) { + filter.value = filter.value.copyWith( + filename: null, + context: value, + ); + } else { + filter.value = filter.value.copyWith( + filename: value, + context: null, + ); + } + + search(); + } + + return Scaffold( + resizeToAvoidBottomInset: true, + appBar: AppBar( + automaticallyImplyLeading: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 14.0), + child: IconButton( + icon: isContextualSearch.value + ? const Icon(Icons.abc_rounded) + : const Icon(Icons.image_search_rounded), + onPressed: () { + isContextualSearch.value = !isContextualSearch.value; + textSearchController.clear(); + }, + ), + ), + ], + title: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + 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), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: TextField( + controller: textSearchController, + decoration: InputDecoration( + contentPadding: prefilter != null + ? EdgeInsets.only(left: 24) + : EdgeInsets.all(8), + prefixIcon: prefilter != null + ? null + : Icon( + Icons.search_rounded, + color: context.colorScheme.primary, + ), + hintText: isContextualSearch.value + ? 'contextual_search'.tr() + : 'filename_search'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.themeData.colorScheme.onSurfaceSecondary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), + ), + onSubmitted: handleTextSubmitted, + focusNode: ref.watch(searchInputFocusProvider), + onTapOutside: (_) => ref.read(searchInputFocusProvider).unfocus(), + ), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: SizedBox( + height: 50, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + SearchFilterChip( + icon: Icons.people_alt_rounded, + onTap: showPeoplePicker, + label: 'search_filter_people'.tr(), + currentFilter: peopleCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.location_pin, + onTap: showLocationPicker, + label: 'search_filter_location'.tr(), + currentFilter: locationCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.camera_alt_rounded, + onTap: showCameraPicker, + label: 'search_filter_camera'.tr(), + currentFilter: cameraCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.date_range_rounded, + onTap: showDatePicker, + label: 'search_filter_date'.tr(), + currentFilter: dateRangeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.video_collection_outlined, + onTap: showMediaTypePicker, + label: 'search_filter_media_type'.tr(), + currentFilter: mediaTypeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.display_settings_outlined, + onTap: showDisplayOptionPicker, + label: 'search_filter_display_options'.tr(), + currentFilter: displayOptionCurrentFilterWidget.value, + ), + ], + ), + ), + ), + SearchResultGrid( + onScrollEnd: loadMoreSearchResult, + isSearching: isSearching.value, + ), + ], + ), + ); + } +} + +class SearchResultGrid extends StatelessWidget { + final VoidCallback onScrollEnd; + final bool isSearching; + + const SearchResultGrid({ + super.key, + required this.onScrollEnd, + this.isSearching = false, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: NotificationListener( + onNotification: (notification) { + final isBottomSheetNotification = notification.context + ?.findAncestorWidgetOfExactType< + DraggableScrollableSheet>() != + null; + + final metrics = notification.metrics; + final isVerticalScroll = metrics.axis == Axis.vertical; + + if (metrics.pixels >= metrics.maxScrollExtent && + isVerticalScroll && + !isBottomSheetNotification) { + onScrollEnd(); + } + + return true; + }, + child: MultiselectGrid( + renderListProvider: paginatedSearchRenderListProvider, + archiveEnabled: true, + deleteEnabled: true, + editEnabled: true, + favoriteEnabled: true, + stackEnabled: false, + emptyIndicator: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: !isSearching ? SearchEmptyContent() : SizedBox.shrink(), + ), + ), + ), + ), + ); + } +} + +class SearchEmptyContent extends StatelessWidget { + const SearchEmptyContent({super.key}); + + @override + Widget build(BuildContext context) { + return ListView( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + SizedBox(height: 40), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/polaroid-dark.png' + : 'assets/polaroid-light.png', + height: 125, + ), + ), + SizedBox(height: 16), + Center( + child: Text( + "Search for your photos and videos", + style: context.textTheme.labelLarge, + ), + ), + SizedBox(height: 32), + QuickLinkList(), + ], + ); + } +} + +class QuickLinkList extends StatelessWidget { + const QuickLinkList({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: context.colorScheme.outline.withAlpha(10), + width: 1, + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + QuickLink( + title: 'recently_added'.tr(), + icon: Icons.schedule_outlined, + isTop: true, + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + QuickLink( + title: 'videos'.tr(), + icon: Icons.play_circle_outline_rounded, + onTap: () => context.pushRoute(AllVideosRoute()), + ), + QuickLink( + title: 'favorites'.tr(), + icon: Icons.favorite_border_rounded, + isBottom: true, + onTap: () => context.pushRoute(FavoritesRoute()), + ), + ], + ), + ); + } +} + +class QuickLink extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final bool isTop; + final bool isBottom; + + const QuickLink({ + super.key, + required this.title, + required this.icon, + required this.onTap, + this.isTop = false, + this.isBottom = false, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.only( + topLeft: Radius.circular(isTop ? 20 : 0), + topRight: Radius.circular(isTop ? 20 : 0), + bottomLeft: Radius.circular(isBottom ? 20 : 0), + bottomRight: Radius.circular(isBottom ? 20 : 0), + ); + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + leading: Icon( + icon, + size: 26, + ), + title: Text( + title, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart deleted file mode 100644 index 1f90f2929c..0000000000 --- a/mobile/lib/pages/search/search_input.page.dart +++ /dev/null @@ -1,581 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; -import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; -import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; -import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; -import 'package:openapi/api.dart'; - -@RoutePage() -class SearchInputPage extends HookConsumerWidget { - const SearchInputPage({super.key, this.prefilter}); - - final SearchFilter? prefilter; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isContextualSearch = useState(true); - final textSearchController = useTextEditingController(); - final filter = useState( - SearchFilter( - people: prefilter?.people ?? {}, - location: prefilter?.location ?? SearchLocationFilter(), - camera: prefilter?.camera ?? SearchCameraFilter(), - date: prefilter?.date ?? SearchDateFilter(), - display: prefilter?.display ?? - SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: prefilter?.mediaType ?? AssetType.other, - ), - ); - - final previousFilter = useState(filter.value); - - final peopleCurrentFilterWidget = useState(null); - final dateRangeCurrentFilterWidget = useState(null); - final cameraCurrentFilterWidget = useState(null); - final locationCurrentFilterWidget = useState(null); - final mediaTypeCurrentFilterWidget = useState(null); - final displayOptionCurrentFilterWidget = useState(null); - - final currentPage = useState(1); - final searchProvider = ref.watch(paginatedSearchProvider); - final searchResultCount = useState(0); - - search() async { - if (prefilter == null && filter.value == previousFilter.value) return; - - ref.watch(paginatedSearchProvider.notifier).clear(); - - currentPage.value = 1; - - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - previousFilter.value = filter.value; - - searchResultCount.value = searchResult.length; - } - - searchPrefilter() { - if (prefilter != null) { - Future.delayed( - Duration.zero, - () { - search(); - - if (prefilter!.location.city != null) { - locationCurrentFilterWidget.value = Text( - prefilter!.location.city!, - style: context.textTheme.labelLarge, - ); - } - }, - ); - } - } - - useEffect( - () { - searchPrefilter(); - return null; - }, - [], - ); - - loadMoreSearchResult() async { - currentPage.value += 1; - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - searchResultCount.value = searchResult.length; - } - - showPeoplePicker() { - handleOnSelect(Set value) { - filter.value = filter.value.copyWith( - people: value, - ); - - peopleCurrentFilterWidget.value = Text( - value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - people: {}, - ); - - peopleCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - child: FractionallySizedBox( - heightFactor: 0.8, - child: FilterBottomSheetScaffold( - title: 'search_filter_people_title'.tr(), - expanded: true, - onSearch: search, - onClear: handleClear, - child: PeoplePicker( - onSelect: handleOnSelect, - filter: filter.value.people, - ), - ), - ), - ); - } - - showLocationPicker() { - handleOnSelect(Map value) { - filter.value = filter.value.copyWith( - location: SearchLocationFilter( - country: value['country'], - city: value['city'], - state: value['state'], - ), - ); - - final locationText = []; - if (value['country'] != null) { - locationText.add(value['country']!); - } - - if (value['state'] != null) { - locationText.add(value['state']!); - } - - if (value['city'] != null) { - locationText.add(value['city']!); - } - - locationCurrentFilterWidget.value = Text( - locationText.join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - location: SearchLocationFilter(), - ); - - locationCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - child: FilterBottomSheetScaffold( - title: 'search_filter_location_title'.tr(), - onSearch: search, - onClear: handleClear, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: LocationPicker( - onSelected: handleOnSelect, - filter: filter.value.location, - ), - ), - ), - ), - ), - ); - } - - showCameraPicker() { - handleOnSelect(Map value) { - filter.value = filter.value.copyWith( - camera: SearchCameraFilter( - make: value['make'], - model: value['model'], - ), - ); - - cameraCurrentFilterWidget.value = Text( - '${value['make'] ?? ''} ${value['model'] ?? ''}', - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - camera: SearchCameraFilter(), - ); - - cameraCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - child: FilterBottomSheetScaffold( - title: 'search_filter_camera_title'.tr(), - onSearch: search, - onClear: handleClear, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: CameraPicker( - onSelect: handleOnSelect, - filter: filter.value.camera, - ), - ), - ), - ); - } - - showDatePicker() async { - final firstDate = DateTime(1900); - final lastDate = DateTime.now(); - - final date = await showDateRangePicker( - context: context, - firstDate: firstDate, - lastDate: lastDate, - currentDate: DateTime.now(), - initialDateRange: DateTimeRange( - start: filter.value.date.takenAfter ?? lastDate, - end: filter.value.date.takenBefore ?? lastDate, - ), - helpText: 'search_filter_date_title'.tr(), - cancelText: 'action_common_cancel'.tr(), - confirmText: 'action_common_select'.tr(), - saveText: 'action_common_save'.tr(), - errorFormatText: 'invalid_date_format'.tr(), - errorInvalidText: 'invalid_date'.tr(), - fieldStartHintText: 'start_date'.tr(), - fieldEndHintText: 'end_date'.tr(), - initialEntryMode: DatePickerEntryMode.input, - ); - - if (date == null) { - filter.value = filter.value.copyWith( - date: SearchDateFilter(), - ); - - dateRangeCurrentFilterWidget.value = null; - search(); - return; - } - - filter.value = filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add( - const Duration( - hours: 23, - minutes: 59, - seconds: 59, - ), - ), - ), - ); - - // If date range is less than 24 hours, set the end date to the end of the day - if (date.end.difference(date.start).inHours < 24) { - dateRangeCurrentFilterWidget.value = Text( - DateFormat.yMMMd().format(date.start.toLocal()), - style: context.textTheme.labelLarge, - ); - } else { - dateRangeCurrentFilterWidget.value = Text( - 'search_filter_date_interval'.tr( - namedArgs: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), - }, - ), - style: context.textTheme.labelLarge, - ); - } - - search(); - } - - // MEDIA PICKER - showMediaTypePicker() { - handleOnSelected(AssetType assetType) { - filter.value = filter.value.copyWith( - mediaType: assetType, - ); - - mediaTypeCurrentFilterWidget.value = Text( - assetType == AssetType.image - ? 'search_filter_media_type_image'.tr() - : assetType == AssetType.video - ? 'search_filter_media_type_video'.tr() - : 'search_filter_media_type_all'.tr(), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - mediaType: AssetType.other, - ); - - mediaTypeCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - child: FilterBottomSheetScaffold( - title: 'search_filter_media_type_title'.tr(), - onSearch: search, - onClear: handleClear, - child: MediaTypePicker( - onSelect: handleOnSelected, - filter: filter.value.mediaType, - ), - ), - ); - } - - // DISPLAY OPTION - showDisplayOptionPicker() { - handleOnSelect(Map value) { - final filterText = []; - - value.forEach((key, value) { - switch (key) { - case DisplayOption.notInAlbum: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isNotInAlbum: value, - ), - ); - if (value) { - filterText - .add('search_filter_display_option_not_in_album'.tr()); - } - break; - case DisplayOption.archive: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isArchive: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_archive'.tr()); - } - break; - case DisplayOption.favorite: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isFavorite: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_favorite'.tr()); - } - break; - } - }); - - displayOptionCurrentFilterWidget.value = Text( - filterText.join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - ); - - displayOptionCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - child: FilterBottomSheetScaffold( - title: 'search_filter_display_options_title'.tr(), - onSearch: search, - onClear: handleClear, - child: DisplayOptionPicker( - onSelect: handleOnSelect, - filter: filter.value.display, - ), - ), - ); - } - - handleTextSubmitted(String value) { - if (isContextualSearch.value) { - filter.value = filter.value.copyWith( - context: value, - filename: null, - ); - } else { - filter.value = filter.value.copyWith(filename: value, context: null); - } - - search(); - } - - buildSearchResult() { - return switch (searchProvider) { - AsyncData() => Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: NotificationListener( - onNotification: (notification) { - final metrics = notification.metrics; - final shouldLoadMore = searchResultCount.value > 75; - if (metrics.pixels >= metrics.maxScrollExtent && - shouldLoadMore) { - loadMoreSearchResult(); - } - return true; - }, - child: MultiselectGrid( - renderListProvider: paginatedSearchRenderListProvider, - archiveEnabled: true, - deleteEnabled: true, - editEnabled: true, - favoriteEnabled: true, - stackEnabled: false, - emptyIndicator: const SizedBox(), - ), - ), - ), - ), - AsyncError(:final error) => Text('Error: $error'), - _ => const Expanded(child: Center(child: CircularProgressIndicator())), - }; - } - - return Scaffold( - resizeToAvoidBottomInset: true, - appBar: AppBar( - automaticallyImplyLeading: true, - actions: [ - IconButton( - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); - }, - ), - ], - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: () => context.router.maybePop(), - ), - title: TextField( - controller: textSearchController, - decoration: InputDecoration( - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), - hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.themeData.colorScheme.onSurface.withOpacity(0.75), - fontWeight: FontWeight.w500, - ), - enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), - ), - ), - onSubmitted: handleTextSubmitted, - ), - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: SizedBox( - height: 50, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - SearchFilterChip( - icon: Icons.people_alt_rounded, - onTap: showPeoplePicker, - label: 'search_filter_people'.tr(), - currentFilter: peopleCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.location_pin, - onTap: showLocationPicker, - label: 'search_filter_location'.tr(), - currentFilter: locationCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.camera_alt_rounded, - onTap: showCameraPicker, - label: 'search_filter_camera'.tr(), - currentFilter: cameraCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.date_range_rounded, - onTap: showDatePicker, - label: 'search_filter_date'.tr(), - currentFilter: dateRangeCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.video_collection_outlined, - onTap: showMediaTypePicker, - label: 'search_filter_media_type'.tr(), - currentFilter: mediaTypeCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.display_settings_outlined, - onTap: showDisplayOptionPicker, - label: 'search_filter_display_options'.tr(), - currentFilter: displayOptionCurrentFilterWidget.value, - ), - ], - ), - ), - ), - buildSearchResult(), - ], - ), - ); - } -} diff --git a/mobile/lib/pages/sharing/sharing.page.dart b/mobile/lib/pages/sharing/sharing.page.dart deleted file mode 100644 index 45148945ed..0000000000 --- a/mobile/lib/pages/sharing/sharing.page.dart +++ /dev/null @@ -1,276 +0,0 @@ -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/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/providers/partner.provider.dart'; -import 'package:immich_mobile/widgets/partner/partner_list.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; - -@RoutePage() -class SharingPage extends HookConsumerWidget { - const SharingPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - final albums = ref.watch(sharedAlbumProvider); - final sharedAlbums = albumSortOption.sortFn(albums, albumSortIsReverse); - final userId = ref.watch(currentUserProvider)?.id; - final partner = ref.watch(partnerSharedWithProvider); - - useEffect( - () { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - return null; - }, - [], - ); - - buildAlbumGrid() { - return SliverPadding( - padding: const EdgeInsets.all(18.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - return AlbumThumbnailCard( - album: sharedAlbums[index], - showOwner: true, - onTap: () => context.pushRoute( - AlbumViewerRoute(albumId: sharedAlbums[index].id), - ), - ); - }, - childCount: sharedAlbums.length, - ), - ), - ); - } - - buildAlbumList() { - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final album = sharedAlbums[index]; - final isOwner = album.ownerId == userId; - - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichThumbnail( - asset: album.thumbnail.value, - width: 60, - height: 60, - ), - ), - title: Text( - album.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.primaryColor, - fontWeight: FontWeight.w500, - ), - ), - subtitle: isOwner - ? Text( - 'album_thumbnail_owned'.tr(), - style: context.textTheme.bodyMedium, - ) - : album.ownerName != null - ? Text( - 'album_thumbnail_shared_by' - .tr(args: [album.ownerName!]), - style: context.textTheme.bodyMedium, - ) - : null, - onTap: () => context - .pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)), - ); - }, - childCount: sharedAlbums.length, - ), - ); - } - - buildTopBottons() { - return Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: true)), - icon: const Icon( - Icons.photo_album_outlined, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_create_shared_album", - maxLines: 1, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ).tr(), - ), - ), - const SizedBox(width: 12.0), - Expanded( - child: ElevatedButton.icon( - onPressed: () => context.pushRoute(const SharedLinkRoute()), - icon: const Icon( - Icons.link, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_shared_links", - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - maxLines: 1, - ).tr(), - ), - ), - ], - ), - ); - } - - buildEmptyListIndication() { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - side: BorderSide( - color: Colors.grey, - width: 0.5, - ), - ), - child: Padding( - padding: const EdgeInsets.all(18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 5.0, bottom: 5), - child: Icon( - Icons.insert_photo_rounded, - size: 50, - color: context.primaryColor, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_empty_list', - style: context.textTheme.displaySmall, - ).tr(), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_description', - style: context.textTheme.bodyMedium, - ).tr(), - ), - ], - ), - ), - ), - ), - ); - } - - Widget sharePartnerButton() { - return InkWell( - onTap: () => context.pushRoute(const PartnerRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.swap_horizontal_circle_rounded, - size: 25, - semanticLabel: 'partner_page_title'.tr(), - ), - ); - } - - return RefreshIndicator( - onRefresh: () async { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - }, - child: Scaffold( - appBar: ImmichAppBar( - action: sharePartnerButton(), - ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter(child: buildTopBottons()), - if (partner.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "partner_page_title", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - if (partner.isNotEmpty) PartnerList(partner: partner), - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "sharing_page_album", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (sharedAlbums.isEmpty) { - return buildEmptyListIndication(); - } - - if (constraints.crossAxisExtent < 600) { - return buildAlbumList(); - } else { - return buildAlbumGrid(); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index dcfaac883f..6bd139c565 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,9 +1,9 @@ +import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod ActivityService activityService(ActivityServiceRef ref) => - ActivityService(ref.watch(apiServiceProvider)); + 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 8e5ef43260..d42b2a39e4 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'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; +String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index afb43e8cba..b1d2b4b987 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics { ref .watch(activityServiceProvider) .getStatistics(albumId, assetId: assetId) - .then((comments) => state = comments); + .then((stats) => state = stats.comments); return 0; } diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 79856c525b..16a3c0e81b 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart'; // ************************************************************************** String _$activityStatisticsHash() => - r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + r'1f43f0bcb11c754ca3cb586a13570db25023b9a8'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 8251d5e66b..53c8855c0a 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -1,21 +1,21 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.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/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.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, Isar db) : super([]) { - final query = db.albums - .filter() - .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); + AlbumNotifier(this._albumService, this.db, this.ref) : super([]) { + final query = db.albums.filter().remoteIdIsNotNull(); query.findAll().then((value) { if (mounted) { state = value; @@ -23,15 +23,19 @@ class AlbumNotifier extends StateNotifier> { }); _streamSub = query.watch().listen((data) => state = data); } + final AlbumService _albumService; + final Isar db; + final Ref ref; late final StreamSubscription> _streamSub; - Future getAllAlbums() => Future.wait([ - _albumService.refreshDeviceAlbums(), - _albumService.refreshRemoteAlbums(isShared: false), - ]); + Future refreshRemoteAlbums() async { + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; + await _albumService.refreshRemoteAlbums(); + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; + } - Future getDeviceAlbums() => _albumService.refreshDeviceAlbums(); + Future refreshDeviceAlbums() => _albumService.refreshDeviceAlbums(); Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); @@ -41,6 +45,67 @@ class AlbumNotifier extends StateNotifier> { ) => _albumService.createAlbum(albumTitle, assets, []); + Future getAlbumByName(String albumName, {bool remoteOnly = false}) => + _albumService.getAlbumByName(albumName, remoteOnly); + + /// Create an album on the server with the same name as the selected album for backup + /// First this will check if the album already exists on the server with name + /// If it does not exist, it will create the album on the server + Future createSyncAlbum( + String albumName, + ) async { + final album = await getAlbumByName(albumName, remoteOnly: true); + if (album != null) { + return; + } + + await createAlbum(albumName, {}); + } + + Future leaveAlbum(Album album) async { + var res = await _albumService.leaveAlbum(album); + + if (res) { + await deleteAlbum(album); + return true; + } else { + return false; + } + } + + void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { + state = await _albumService.search(searchTerm, filterMode); + } + + Future addUsers(Album album, List userIds) async { + await _albumService.addUsers(album, userIds); + } + + Future removeUser(Album album, User user) async { + final isRemoved = await _albumService.removeUser(album, user); + + if (isRemoved && album.sharedUsers.isEmpty) { + state = state.where((element) => element.id != album.id).toList(); + } + + return isRemoved; + } + + Future addAssets(Album album, Iterable assets) async { + await _albumService.addAssets(album, assets); + } + + Future removeAsset(Album album, Iterable assets) async { + return await _albumService.removeAsset(album, assets); + } + + Future setActivitystatus( + Album album, + bool enabled, + ) { + return _albumService.setActivityStatus(album, enabled); + } + @override void dispose() { _streamSub.cancel(); @@ -53,6 +118,7 @@ final albumProvider = return AlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), + ref, ); }); @@ -76,3 +142,31 @@ final albumRenderlistProvider = } return const Stream.empty(); }); + +class LocalAlbumsNotifier extends StateNotifier> { + LocalAlbumsNotifier(this.db) : super([]) { + final query = db.albums.where().remoteIdIsNull(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + + _streamSub = query.watch().listen((data) => state = data); + } + + final Isar db; + late final StreamSubscription> _streamSub; + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +final localAlbumsProvider = + StateNotifierProvider.autoDispose>((ref) { + return LocalAlbumsNotifier(ref.watch(dbProvider)); +}); diff --git a/mobile/lib/providers/album/album_viewer.provider.dart b/mobile/lib/providers/album/album_viewer.provider.dart index f34ff4ef22..e418657782 100644 --- a/mobile/lib/providers/album/album_viewer.provider.dart +++ b/mobile/lib/providers/album/album_viewer.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -40,7 +39,6 @@ class AlbumViewerNotifier extends StateNotifier { if (isSuccess) { state = state.copyWith(editTitleText: "", isEditAlbum: false); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); return true; } diff --git a/mobile/lib/providers/album/shared_album.provider.dart b/mobile/lib/providers/album/shared_album.provider.dart deleted file mode 100644 index 0d58135375..0000000000 --- a/mobile/lib/providers/album/shared_album.provider.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/album.service.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/providers/db.provider.dart'; -import 'package:isar/isar.dart'; - -class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._albumService, Isar db) : super([]) { - final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); - query.findAll().then((value) { - if (mounted) { - state = value; - } - }); - _streamSub = query.watch().listen((data) => state = data); - } - - final AlbumService _albumService; - late final StreamSubscription> _streamSub; - - Future createSharedAlbum( - String albumName, - Iterable assets, - Iterable sharedUsers, - ) async { - try { - return await _albumService.createAlbum( - albumName, - assets, - sharedUsers, - ); - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; - } - - Future getAllSharedAlbums() => - _albumService.refreshRemoteAlbums(isShared: true); - - Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); - - Future leaveAlbum(Album album) async { - var res = await _albumService.leaveAlbum(album); - - if (res) { - await deleteAlbum(album); - return true; - } else { - return false; - } - } - - Future removeAssetFromAlbum(Album album, Iterable assets) { - return _albumService.removeAssetFromAlbum(album, assets); - } - - Future removeUserFromAlbum(Album album, User user) async { - final result = await _albumService.removeUserFromAlbum(album, user); - - if (result && album.sharedUsers.isEmpty) { - state = state.where((element) => element.id != album.id).toList(); - } - - return result; - } - - Future setActivityEnabled(Album album, bool activityEnabled) { - return _albumService.setActivityEnabled(album, activityEnabled); - } - - @override - void dispose() { - _streamSub.cancel(); - super.dispose(); - } -} - -final sharedAlbumProvider = - StateNotifierProvider.autoDispose>((ref) { - return SharedAlbumNotifier( - ref.watch(albumServiceProvider), - ref.watch(dbProvider), - ); -}); diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 77518f47d0..fe8a1fccce 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return userService.getUsersInDb(); + return userService.getUsers(); }); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 938961efb6..c06a99da35 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -58,11 +57,10 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(assetProvider.notifier).getAllAsset(); case TabEnum.search: // nothing to do - case TabEnum.sharing: - _ref.read(assetProvider.notifier).getAllAsset(); - _ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + case TabEnum.albums: + _ref.read(albumProvider.notifier).refreshRemoteAlbums(); case TabEnum.library: - _ref.read(albumProvider.notifier).getAllAlbums(); + // nothing to do } } @@ -86,12 +84,16 @@ class AppLifeCycleNotifier extends StateNotifier { void handleAppPause() { state = AppLifeCycleEnum.paused; _wasPaused = true; - // Do not cancel backup if manual upload is in progress - if (_ref.read(backupProvider.notifier).backupProgress != - BackUpProgressEnum.manualInProgress) { - _ref.read(backupProvider.notifier).cancelBackup(); + + if (_ref.read(authenticationProvider).isAuthenticated) { + // Do not cancel backup if manual upload is in progress + if (_ref.read(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + _ref.read(backupProvider.notifier).cancelBackup(); + } + _ref.read(websocketProvider.notifier).disconnect(); } - _ref.read(websocketProvider.notifier).disconnect(); + ImmichLogger().flush(); } diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a0a3879db5..c7e75df79b 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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'; @@ -15,7 +16,6 @@ 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'; -import 'package:photo_manager/photo_manager.dart'; class AssetNotifier extends StateNotifier { final AssetService _assetService; @@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier { // Delete asset from device if (local.isNotEmpty) { try { - return await PhotoManager.editor.deleteWithIds(local); + return await _ref.read(assetMediaRepositoryProvider).deleteAll(local); } catch (e, stack) { log.severe("Failed to delete asset from device", e, stack); } @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } @@ -360,7 +346,7 @@ QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } @@ -374,6 +360,6 @@ QueryBuilder _commonFilterAndSort( .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdIsNull() + .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); } diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index 0883ed92db..c3e4414b39 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -48,7 +48,7 @@ final assetStackProvider = .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackParentIdEqualTo(asset.remoteId) + .stackPrimaryAssetIdEqualTo(asset.remoteId) .sortByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000..68b120c38a --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,196 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + final AlbumService _albumService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + this._albumService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImageWithPath(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + _albumService.refreshDeviceAlbums(); + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + ref.watch(albumServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index ee45e6bc5e..0000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,95 +0,0 @@ -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/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_download_success'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index 5846bb78cc..1fe7db5d46 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -115,7 +114,6 @@ class AuthenticationNotifier extends StateNotifier { Store.delete(StoreKey.accessToken), ]); _ref.invalidate(albumProvider); - _ref.invalidate(sharedAlbumProvider); state = state.copyWith( deviceId: "", @@ -170,8 +168,10 @@ class AuthenticationNotifier extends StateNotifier { UserPreferencesResponseDto? userPreferences; try { final responses = await Future.wait([ - _apiService.usersApi.getMyUser(), - _apiService.usersApi.getMyPreferences(), + _apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)), + _apiService.usersApi + .getMyPreferences() + .timeout(const Duration(seconds: 7)), ]); userResponse = responses[0] as UserAdminResponseDto; userPreferences = responses[1] as UserPreferencesResponseDto; @@ -190,6 +190,9 @@ class AuthenticationNotifier extends StateNotifier { error, stackTrace, ); + debugPrint( + "Error getting user information from the server [CATCH ALL] $error $stackTrace", + ); } // If the user information is successfully retrieved, update the store diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 58027e3b94..dc6d2f7cc8 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -2,14 +2,24 @@ import 'dart:io'; import 'package:cancellation_token_http/http.dart'; 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/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/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/providers/backup/error_backup_list.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/authentication/authentication_state.model.dart'; @@ -25,7 +35,7 @@ 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'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; class BackupNotifier extends StateNotifier { BackupNotifier( @@ -35,6 +45,9 @@ class BackupNotifier extends StateNotifier { this._backgroundService, this._galleryPermissionNotifier, this._db, + this._albumMediaRepository, + this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -83,6 +96,9 @@ class BackupNotifier extends StateNotifier { final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; final Isar _db; + final IAlbumMediaRepository _albumMediaRepository; + final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -221,38 +237,44 @@ class BackupNotifier extends StateNotifier { Stopwatch stopwatch = Stopwatch()..start(); // Get all albums on the device List availableAlbums = []; - List albums = await PhotoManager.getAssetPathList( - hasAll: true, - type: RequestType.common, - ); + List albums = await _albumMediaRepository.getAll(); // Map of id -> album for quick album lookup later on. - Map albumMap = {}; + Map albumMap = {}; log.info('Found ${albums.length} local albums'); - for (AssetPathEntity album in albums) { - AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); + for (Album album in albums) { + AvailableAlbum availableAlbum = AvailableAlbum( + album: album, + assetCount: await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.localId!), + ); availableAlbums.add(availableAlbum); - albumMap[album.id] = album; + albumMap[album.localId!] = album; } state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); - // Generate AssetPathEntity from id to add to local state final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { final albumAsset = albumMap[ba.id]; if (albumAsset != null) { selectedAlbums.add( - AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), + AvailableAlbum( + album: albumAsset, + assetCount: + await _albumMediaRepository.getAssetCount(albumAsset.localId!), + lastBackup: ba.lastBackup, + ), ); } else { log.severe('Selected album not found'); @@ -265,7 +287,13 @@ class BackupNotifier extends StateNotifier { if (albumAsset != null) { excludedAlbums.add( - AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), + AvailableAlbum( + album: albumAsset, + assetCount: await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(albumAsset.localId!), + lastBackup: ba.lastBackup, + ), ); } else { log.severe('Excluded album not found'); @@ -289,40 +317,71 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { + // Save to persistent storage + await _updatePersistentAlbumsSelection(); + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); - final Set assetsFromSelectedAlbums = {}; - final Set assetsFromExcludedAlbums = {}; + final Set assetsFromSelectedAlbums = {}; + final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { - final assetCount = await album.albumEntity.assetCountAsync; + final assetCount = await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.album.localId!); if (assetCount == 0) { continue; } - final assets = await album.albumEntity.getAssetListRange( - start: 0, - end: assetCount, - ); - assetsFromSelectedAlbums.addAll(assets); + final assets = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.album.localId!); + + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [album.name]; + + final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( + (a) => a.asset.localId == asset.localId, + ); + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + assetsFromSelectedAlbums.remove(existingAsset); + } + + assetsFromSelectedAlbums.add( + BackupCandidate( + asset: asset, + albumNames: albumNames, + ), + ); + } } for (final album in state.excludedBackupAlbums) { - final assetCount = await album.albumEntity.assetCountAsync; + final assetCount = await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.album.localId!); if (assetCount == 0) { continue; } - final assets = await album.albumEntity.getAssetListRange( - start: 0, - end: assetCount, - ); - assetsFromExcludedAlbums.addAll(assets); + final assets = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.album.localId!); + + for (final asset in assets) { + assetsFromExcludedAlbums.add( + BackupCandidate(asset: asset, albumNames: [album.name]), + ); + } } - final Set allUniqueAssets = + final Set allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); + final allAssetsInDatabase = await _backupService.getDeviceBackupAsset(); if (allAssetsInDatabase == null) { @@ -331,14 +390,14 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(allUniqueAssets.map((e) => e.id)); + Set.from(allUniqueAssets.map((e) => e.asset.localId)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( - (asset) => duplicatedAssetIds.contains(asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.localId), ); if (allUniqueAssets.isEmpty) { @@ -356,9 +415,6 @@ class BackupNotifier extends StateNotifier { selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } - - // Save to persistent storage - await _updatePersistentAlbumsSelection(); } /// Get all necessary information for calculating the available albums, @@ -425,7 +481,7 @@ class BackupNotifier extends StateNotifier { final hasPermission = _galleryPermissionNotifier.hasPermission; if (hasPermission) { - await PhotoManager.clearFileCache(); + await _fileMediaRepository.clearFileCache(); if (state.allUniqueAssets.isEmpty) { log.info("No Asset On Device - Abort Backup Process"); @@ -433,10 +489,10 @@ class BackupNotifier extends StateNotifier { return; } - Set assetsWillBeBackup = Set.from(state.allUniqueAssets); + Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { - assetsWillBeBackup.removeWhere((e) => e.id == assetId); + assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId); } if (assetsWillBeBackup.isEmpty) { @@ -456,11 +512,11 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onUploadProgress, - _onSetCurrentBackupAsset, - _onBackupError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onUploadProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onBackupError, ); await notifyBackgroundServiceCanRun(); } else { @@ -497,34 +553,37 @@ class BackupNotifier extends StateNotifier { ); } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { - if (isDuplicated) { + void _onAssetUploaded(SuccessUploadAsset result) async { + if (result.isDuplicate) { state = state.copyWith( allUniqueAssets: state.allUniqueAssets - .where((asset) => asset.id != deviceAssetId) + .where( + (candidate) => + candidate.asset.localId != result.candidate.asset.localId, + ) .toSet(), ); } else { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, - deviceAssetId, + result.candidate.asset.localId!, }, - allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId], + allAssetsInDatabase: [ + ...state.allAssetsInDatabase, + result.candidate.asset.localId!, + ], ); } if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { - final latestAssetBackup = - state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce( - (v, e) => e.isAfter(v) ? e : v, - ); + final latestAssetBackup = state.allUniqueAssets + .map((candidate) => candidate.asset.fileModifiedAt) + .reduce( + (v, e) => e.isAfter(v) ? e : v, + ); state = state.copyWith( selectedBackupAlbums: state.selectedBackupAlbums .map((e) => e.copyWith(lastBackup: latestAssetBackup)) @@ -710,6 +769,9 @@ final backupProvider = 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.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index 894b807ec8..7b8e7b8c4b 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification { return; } final connection = await Connectivity().checkConnectivity(); - if (connection != ConnectivityResult.wifi) { + if (connection.contains(ConnectivityResult.wifi)) { if (context.mounted) { ImmichToast.show( context: context, diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index f222c9bd83..e286f43421 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart'; // ************************************************************************** String _$backupVerificationHash() => - r'b691e0cc27856eef189258d3c102cc73ce4812a4'; + r'021dfdf65e1903c932e4a1c14967b786dd3516fb'; /// See also [BackupVerification]. @ProviderFor(BackupVerification) diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index b446711226..192126f085 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,6 +6,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; 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'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -24,13 +29,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final manualUploadProvider = StateNotifierProvider((ref) { return ManualUploadNotifier( ref.watch(localNotificationService), ref.watch(backupProvider.notifier), + ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -39,11 +46,15 @@ class ManualUploadNotifier extends StateNotifier { final Logger _log = Logger("ManualUploadNotifier"); final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; + final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, + this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -115,11 +126,7 @@ class ManualUploadNotifier extends StateNotifier { } } - void _onAssetUploaded( - String deviceAssetId, - String deviceId, - bool isDuplicated, - ) { + void _onAssetUploaded(SuccessUploadAsset result) { state = state.copyWith(successfulUploads: state.successfulUploads + 1); _backupProvider.updateDiskInfo(); } @@ -191,17 +198,10 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress); if (ref.read(galleryPermissionNotifier.notifier).hasPermission) { - await PhotoManager.clearFileCache(); + await ref.read(fileMediaRepositoryProvider).clearFileCache(); - // We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases - // where platform specific fields such as `subtype` used to detect platform specific assets such as - // LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local - List allAssetsFromDevice = await Future.wait( - allManualUploads - // Filter local only assets - .where((e) => e.isLocal && !e.isRemote) - .map((e) => e.local!.obtainForNewProperties()), - ); + final allAssetsFromDevice = + allManualUploads.where((e) => e.isLocal && !e.isRemote).toList(); if (allAssetsFromDevice.length != allManualUploads.length) { _log.warning( @@ -209,9 +209,29 @@ class ManualUploadNotifier extends StateNotifier { ); } - Set allUploadAssets = allAssetsFromDevice.nonNulls.toSet(); + final selectedBackupAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedBackupAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); - if (allUploadAssets.isEmpty) { + // Get candidates from selected albums and excluded albums + Set candidates = + await _backupService.buildUploadCandidates( + selectedBackupAlbums, + excludedBackupAlbums, + useTimeFilter: false, + ); + + // Extrack candidate from allAssetsFromDevice + final uploadAssets = candidates.where( + (candidate) => + allAssetsFromDevice.firstWhereOrNull( + (asset) => asset.localId == candidate.asset.localId, + ) != + null, + ); + + if (uploadAssets.isEmpty) { debugPrint("[_startUpload] No Assets to upload - Abort Process"); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); return false; @@ -221,7 +241,7 @@ class ManualUploadNotifier extends StateNotifier { progressInPercentage: 0, progressInFileSize: "0 B / 0 B", progressInFileSpeed: 0, - totalAssetsToUpload: allUploadAssets.length, + totalAssetsToUpload: uploadAssets.length, successfulUploads: 0, currentAssetIndex: 0, currentUploadAsset: CurrentUploadAsset( @@ -250,13 +270,13 @@ class ManualUploadNotifier extends StateNotifier { final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await ref.read(backupServiceProvider).backupAsset( - allUploadAssets, + uploadAssets, state.cancelToken, - pmProgressHandler, - _onAssetUploaded, - _onProgress, - _onSetCurrentBackupAsset, - _onAssetUploadError, + pmProgressHandler: pmProgressHandler, + onSuccess: _onAssetUploaded, + onProgress: _onProgress, + onCurrentAsset: _onSetCurrentBackupAsset, + onError: _onAssetUploadError, ); // Close detailed notification diff --git a/mobile/lib/providers/favorite_provider.dart b/mobile/lib/providers/favorite.provider.dart similarity index 100% rename from mobile/lib/providers/favorite_provider.dart rename to mobile/lib/providers/favorite.provider.dart diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 7554a6a6bf..8077ca99fe 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -36,7 +36,8 @@ class GalleryPermissionNotifier extends StateNotifier { // Return the joint result of those two permissions final PermissionStatus status; - if (photos.isGranted && videos.isGranted) { + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { status = PermissionStatus.granted; } else if (photos.isDenied || videos.isDenied) { status = PermissionStatus.denied; @@ -79,7 +80,8 @@ class GalleryPermissionNotifier extends StateNotifier { // Return the joint result of those two permissions final PermissionStatus status; - if (photos.isGranted && videos.isGranted) { + if ((photos.isGranted && videos.isGranted) || + (photos.isLimited && videos.isLimited)) { status = PermissionStatus.granted; } else if (photos.isDenied || videos.isDenied) { status = PermissionStatus.denied; diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index cf9cf86090..bbfaf12a4f 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,7 +7,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset class ImmichLocalImageProvider extends ImageProvider { @@ -60,31 +60,16 @@ class ImmichLocalImageProvider extends ImageProvider { } if (asset.isImage) { - /// Using 2K thumbnail for local iOS image to avoid double swiping issue - if (Platform.isIOS) { - final largeImageBytes = await asset.local - ?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160)); - if (largeImageBytes == null) { - throw StateError( - "Loading thumb for local photo ${asset.fileName} failed", - ); - } - final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + final File? file = await asset.local?.originFile; + if (file == null) { + throw StateError("Opening file for asset ${asset.fileName} failed"); + } + try { + final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); final codec = await decode(buffer); yield codec; - } else { - // Use the original file for Android - final File? file = await asset.local?.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); - } - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield codec; - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); - } + } catch (error) { + throw StateError("Loading asset ${asset.fileName} failed"); } } diff --git a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart b/mobile/lib/providers/image/immich_local_thumbnail_provider.dart index 28e78ae762..69cdb105c0 100644 --- a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart +++ b/mobile/lib/providers/image/immich_local_thumbnail_provider.dart @@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset /// Only viable diff --git a/mobile/lib/providers/image/immich_remote_image_provider.dart b/mobile/lib/providers/image/immich_remote_image_provider.dart index 2756ed1dc9..9e1d8aa120 100644 --- a/mobile/lib/providers/image/immich_remote_image_provider.dart +++ b/mobile/lib/providers/image/immich_remote_image_provider.dart @@ -101,7 +101,7 @@ class ImmichRemoteImageProvider // Load the final remote image if (_useOriginal) { // Load the original image - final url = getImageUrlFromId(key.assetId); + final url = getOriginalUrlForRemoteId(key.assetId); final codec = await ImageLoader.loadImageFromCache( url, cache: cache, diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 6d1630bba2..189a23cd0a 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -1,28 +1,23 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_state.provider.g.dart'; @Riverpod(keepAlive: true) class MapStateNotifier extends _$MapStateNotifier { - final _log = Logger("MapStateNotifier"); - @override MapState build() { final appSettingsProvider = ref.read(appSettingsServiceProvider); - // Fetch and save the Style JSONs - loadStyles(); + final lightStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl; + final darkStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl; + return MapState( themeMode: ThemeMode.values[ appSettingsProvider.getSetting(AppSettingsEnum.mapThemeMode)], @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier { appSettingsProvider.getSetting(AppSettingsEnum.mapwithPartners), relativeTime: appSettingsProvider.getSetting(AppSettingsEnum.mapRelativeDate), + lightStyleFetched: AsyncData(lightStyleUrl), + darkStyleFetched: AsyncData(darkStyleUrl), ); } - void loadStyles() async { - final documents = (await getApplicationDocumentsDirectory()).path; - - // Set to loading - state = state.copyWith(lightStyleFetched: const AsyncLoading()); - - // Fetch and save light theme - final lightResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.light); - - if (lightResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), - ); - _log.severe( - "Cannot fetch map light style", - lightResponse.toLoggerString(), - ); - return; - } - - final lightJSON = lightResponse.body; - final lightFile = await File("$documents/map-style-light.json") - .writeAsString(lightJSON, flush: true); - - // Update state with path - state = - state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); - - // Set to loading - state = state.copyWith(darkStyleFetched: const AsyncLoading()); - - // Fetch and save dark theme - final darkResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.dark); - - if (darkResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), - ); - _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); - return; - } - - final darkJSON = darkResponse.body; - final darkFile = await File("$documents/map-style-dark.json") - .writeAsString(darkJSON, flush: true); - - // Update state with path - state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); - } - void switchTheme(ThemeMode mode) { ref.read(appSettingsServiceProvider).setSetting( AppSettingsEnum.mapThemeMode, diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index eff7b4b68e..23a570d1c8 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8'; +String _$mapStateNotifierHash() => r'22e4e571bd0730dbc34b109255a62b920e9c7d66'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/providers/search/paginated_search.provider.dart b/mobile/lib/providers/search/paginated_search.provider.dart index abf711f0ad..270f1148e8 100644 --- a/mobile/lib/providers/search/paginated_search.provider.dart +++ b/mobile/lib/providers/search/paginated_search.provider.dart @@ -1,46 +1,39 @@ +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/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'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'paginated_search.provider.g.dart'; -@riverpod -class PaginatedSearch extends _$PaginatedSearch { - Future?> _search(SearchFilter filter, int page) async { - final service = ref.read(searchServiceProvider); - final result = await service.search(filter, page); +final paginatedSearchProvider = + StateNotifierProvider( + (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), +); - return result; - } +class PaginatedSearchNotifier extends StateNotifier { + final SearchService _searchService; - @override - Future> build() async { - return []; - } + PaginatedSearchNotifier(this._searchService) + : super(SearchResult(assets: [], nextPage: 1)); - Future> getNextPage(SearchFilter filter, int nextPage) async { - state = const AsyncValue.loading(); + search(SearchFilter filter) async { + if (state.nextPage == null) return; - final newState = await AsyncValue.guard(() async { - final assets = await _search(filter, nextPage); + final result = await _searchService.search(filter, state.nextPage!); - if (assets != null) { - return [...?state.value, ...assets]; - } - }); + if (result == null) return; - state = newState.valueOrNull == null - ? const AsyncValue.data([]) - : AsyncValue.data(newState.value!); - - return newState.valueOrNull ?? []; + state = SearchResult( + assets: [...state.assets, ...result.assets], + nextPage: result.nextPage, + ); } clear() { - state = const AsyncValue.data([]); + state = SearchResult(assets: [], nextPage: 1); } } @@ -48,15 +41,11 @@ class PaginatedSearch extends _$PaginatedSearch { AsyncValue paginatedSearchRenderList( PaginatedSearchRenderListRef ref, ) { - final assets = ref.watch(paginatedSearchProvider).value; + final result = ref.watch(paginatedSearchProvider); - if (assets != null) { - return ref.watch( - renderListProviderWithGrouping( - (assets, GroupAssetsBy.none), - ), - ); - } else { - return const AsyncValue.loading(); - } + return ref.watch( + renderListProviderWithGrouping( + (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 3357be7776..cdf8cdd741 100644 --- a/mobile/lib/providers/search/paginated_search.provider.g.dart +++ b/mobile/lib/providers/search/paginated_search.provider.g.dart @@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart'; // ************************************************************************** String _$paginatedSearchRenderListHash() => - r'c2cc2381ee6ea8f8e08d6d4c1289bbf0c6b9647e'; + r'4585c832106b16b6d294055f47bbbe83e0802846'; /// See also [paginatedSearchRenderList]. @ProviderFor(paginatedSearchRenderList) @@ -24,21 +24,5 @@ final paginatedSearchRenderListProvider = typedef PaginatedSearchRenderListRef = AutoDisposeProviderRef>; -String _$paginatedSearchHash() => r'8312f358261368cf2b5572b839fdd8f8fbe9a62e'; - -/// See also [PaginatedSearch]. -@ProviderFor(PaginatedSearch) -final paginatedSearchProvider = - AutoDisposeAsyncNotifierProvider>.internal( - PaginatedSearch.new, - name: r'paginatedSearchProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$paginatedSearchHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$PaginatedSearch = AutoDisposeAsyncNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index 753b9f19bb..7c956f0a37 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,14 +1,14 @@ +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'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( GetAllPeopleRef ref, ) async { final PersonService personService = ref.read(personServiceProvider); @@ -22,9 +22,6 @@ Future> getAllPeople( Future personAssets(PersonAssetsRef ref, String personId) async { final PersonService personService = ref.read(personServiceProvider); final assets = await personService.getPersonAssets(personId); - if (assets == null) { - return RenderList.empty(); - } final settings = ref.read(appSettingsServiceProvider); final groupBy = diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index c68f7a75fc..c5ff6287cd 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,12 +6,11 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd'; +String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = - AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -20,8 +19,8 @@ final getAllPeopleProvider = allTransitiveDependencies: null, ); -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; -String _$personAssetsHash() => r'1d6eff5ca3aa630b58c4dad9516193b21896984d'; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart new file mode 100644 index 0000000000..4f6ed41ee0 --- /dev/null +++ b/mobile/lib/providers/search/search_input_focus.provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final searchInputFocusProvider = Provider((ref) { + return FocusNode(); +}); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 6327f992f5..14521b06f6 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + mapLightStyleUrl: + 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), serverDiskInfo: const ServerDiskInfo( diskAvailable: "0", diff --git a/mobile/lib/providers/tab.provider.dart b/mobile/lib/providers/tab.provider.dart index 2abed7c395..a4875115ce 100644 --- a/mobile/lib/providers/tab.provider.dart +++ b/mobile/lib/providers/tab.provider.dart @@ -1,11 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -enum TabEnum { - home, - search, - sharing, - library, -} +enum TabEnum { home, search, albums, library } /// Provides the currently active tab final tabProvider = StateProvider( diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 45ab1a5185..8bbac853c7 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -167,6 +167,6 @@ final trashedAssetsProvider = StreamProvider((ref) { .filter() .ownerIdEqualTo(user.isarId) .isTrashedEqualTo(true) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); }); diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart new file mode 100644 index 0000000000..8da3759709 --- /dev/null +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -0,0 +1,67 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final activityApiRepositoryProvider = Provider( + (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), +); + +class ActivityApiRepository extends ApiRepository + implements IActivityApiRepository { + final ActivitiesApi _api; + + ActivityApiRepository(this._api); + + @override + Future> getAll(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivities(albumId, assetId: assetId)); + return response.map(_toActivity).toList(); + } + + @override + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + final dto = ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ); + final response = await checkNull(_api.createActivity(dto)); + return _toActivity(response); + } + + @override + Future delete(String id) { + return checkNull(_api.deleteActivity(id)); + } + + @override + Future getStats(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivityStatistics(albumId, assetId: assetId)); + return ActivityStats(comments: response.comments); + } + + static Activity _toActivity(ActivityResponseDto dto) => Activity( + id: dto.id, + createdAt: dto.createdAt, + type: dto.type == ReactionType.comment + ? ActivityType.comment + : ActivityType.like, + user: User.fromSimpleUserDto(dto.user), + assetId: dto.assetId, + comment: dto.comment, + ); +} diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart new file mode 100644 index 0000000000..2c78e4c238 --- /dev/null +++ b/mobile/lib/repositories/album.repository.dart @@ -0,0 +1,152 @@ +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/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final albumRepositoryProvider = + Provider((ref) => AlbumRepository(ref.watch(dbProvider))); + +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { + AlbumRepository(super.db); + + @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(); + } + return query.count(); + } + + @override + Future create(Album album) => txn(() => db.albums.store(album)); + + @override + Future getByName(String name, {bool? shared, bool? remote}) { + var query = db.albums.filter().nameEqualTo(name); + if (shared != null) { + query = query.sharedEqualTo(shared); + } + if (remote == true) { + query = query.localIdIsNull(); + } else if (remote == false) { + query = query.remoteIdIsNull(); + } + return query.findFirst(); + } + + @override + Future update(Album album) => txn(() => db.albums.store(album)); + + @override + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); + + @override + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); + } + QueryBuilder filterQuery = + afterWhere.filter().noOp(); + if (shared != null) { + filterQuery = filterQuery.sharedEqualTo(true); + } + 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(); + } + return query.findAll(); + } + + @override + Future get(int id) => db.albums.get(id); + + @override + Future removeUsers(Album album, List users) => + txn(() => album.sharedUsers.update(unlink: users)); + + @override + Future addAssets(Album album, List assets) => + txn(() => album.assets.update(link: assets)); + + @override + Future removeAssets(Album album, List assets) => + txn(() => album.assets.update(unlink: assets)); + + @override + Future recalculateMetadata(Album album) async { + album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); + album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); + album.lastModifiedAssetTimestamp = + await album.assets.filter().updatedAtProperty().max(); + return album; + } + + @override + Future addUsers(Album album, List users) => + txn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); + + @override + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + var query = db.albums + .filter() + .nameContains(searchTerm, caseSensitive: false) + .remoteIdIsNotNull(); + + switch (filterMode) { + case QuickFilterMode.sharedWithMe: + 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(); + } +} diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart new file mode 100644 index 0000000000..5d0b56dc78 --- /dev/null +++ b/mobile/lib/repositories/album_api.repository.dart @@ -0,0 +1,166 @@ +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/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final albumApiRepositoryProvider = Provider( + (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), +); + +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { + final AlbumsApi _api; + + AlbumApiRepository(this._api); + + @override + Future get(String id) async { + final dto = await checkNull(_api.getAlbumInfo(id)); + return _toAlbum(dto); + } + + @override + Future> getAll({bool? shared}) async { + final dtos = await checkNull(_api.getAllAlbums(shared: shared)); + return dtos.map(_toAlbum).toList(); + } + + @override + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }) async { + final users = sharedUserIds.map( + (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), + ); + final responseDto = await checkNull( + _api.createAlbum( + CreateAlbumDto( + albumName: name, + assetIds: assetIds.toList(), + albumUsers: users.toList(), + ), + ), + ); + return _toAlbum(responseDto); + } + + @override + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }) async { + final response = await checkNull( + _api.updateAlbumInfo( + albumId, + UpdateAlbumDto( + albumName: name, + albumThumbnailAssetId: thumbnailAssetId, + description: description, + isActivityEnabled: activityEnabled, + ), + ), + ); + return _toAlbum(response); + } + + @override + Future delete(String albumId) { + return _api.deleteAlbum(albumId); + } + + @override + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.addAssetsToAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + + final List added = []; + final List duplicates = []; + + for (final result in response) { + if (result.success) { + added.add(result.id); + } else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) { + duplicates.add(result.id); + } + } + return (added: added, duplicates: duplicates); + } + + @override + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.removeAssetFromAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + final List removed = [], failed = []; + for (final dto in response) { + if (dto.success) { + removed.add(dto.id); + } else { + failed.add(dto.id); + } + } + return (removed: removed, failed: failed); + } + + @override + Future addUsers(String albumId, Iterable userIds) async { + final albumUsers = + userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); + final response = await checkNull( + _api.addUsersToAlbum( + albumId, + AddUsersDto(albumUsers: albumUsers), + ), + ); + return _toAlbum(response); + } + + @override + Future removeUser(String albumId, {required String userId}) { + return _api.removeUserFromAlbum(albumId, userId); + } + + static Album _toAlbum(AlbumResponseDto dto) { + final Album album = Album( + remoteId: dto.id, + name: dto.albumName, + createdAt: dto.createdAt, + modifiedAt: dto.updatedAt, + lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp, + shared: dto.shared, + startDate: dto.startDate, + endDate: dto.endDate, + activityEnabled: dto.isActivityEnabled, + ); + album.remoteAssetCount = dto.assetCount; + album.owner.value = User.fromSimpleUserDto(dto.owner); + album.remoteThumbnailAssetId = dto.albumThumbnailAssetId; + final users = dto.albumUsers + .map((albumUser) => User.fromSimpleUserDto(albumUser.user)); + album.sharedUsers.addAll(users); + final assets = dto.assets.map(Asset.remote).toList(); + album.assets.addAll(assets); + return album; + } +} diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart new file mode 100644 index 0000000000..c3795f75df --- /dev/null +++ b/mobile/lib/repositories/album_media.repository.dart @@ -0,0 +1,93 @@ +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/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:photo_manager/photo_manager.dart' hide AssetType; + +final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository()); + +class AlbumMediaRepository implements IAlbumMediaRepository { + @override + Future> getAll() async { + final List assetPathEntities = + await PhotoManager.getAssetPathList( + hasAll: true, + filterOption: FilterOptionGroup(containsPathModified: true), + ); + return assetPathEntities.map(_toAlbum).toList(); + } + + @override + Future> getAssetIds(String albumId) async { + final album = await AssetPathEntity.fromId(albumId); + final List assets = + await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff); + return assets.map((e) => e.id).toList(); + } + + @override + Future getAssetCount(String albumId) async { + final album = await AssetPathEntity.fromId(albumId); + return album.assetCountAsync; + } + + @override + Future> getAssets( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }) async { + final onDevice = await AssetPathEntity.fromId( + albumId, + filterOption: FilterOptionGroup( + containsPathModified: true, + orders: orderByModificationDate + ? [const OrderOption(type: OrderOptionType.updateDate)] + : [], + imageOption: const FilterOption(needTitle: true), + videoOption: const FilterOption(needTitle: true), + updateTimeCond: modifiedFrom == null && modifiedUntil == null + ? null + : DateTimeCond( + min: modifiedFrom ?? DateTime.utc(-271820), + max: modifiedUntil ?? DateTime.utc(275760), + ), + ), + ); + + final List assets = + await onDevice.getAssetListRange(start: start, end: end); + return assets.map(AssetMediaRepository.toAsset).toList().cast(); + } + + @override + Future get( + String id, { + DateTime? modifiedFrom, + DateTime? modifiedUntil, + }) async { + final assetPathEntity = await AssetPathEntity.fromId(id); + return _toAlbum(assetPathEntity); + } + + static Album _toAlbum(AssetPathEntity assetPathEntity) { + final Album album = Album( + name: assetPathEntity.name, + createdAt: + assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(), + modifiedAt: + assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(), + shared: false, + activityEnabled: false, + ); + album.owner.value = Store.get(StoreKey.currentUser); + album.localId = assetPathEntity.id; + album.isAll = assetPathEntity.isAll; + return album; + } +} diff --git a/mobile/lib/repositories/api.repository.dart b/mobile/lib/repositories/api.repository.dart new file mode 100644 index 0000000000..b454c77f9b --- /dev/null +++ b/mobile/lib/repositories/api.repository.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/constants/errors.dart'; + +abstract class ApiRepository { + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart new file mode 100644 index 0000000000..eaaafd3045 --- /dev/null +++ b/mobile/lib/repositories/asset.repository.dart @@ -0,0 +1,248 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.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/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/interfaces/asset.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final assetRepositoryProvider = + Provider((ref) => AssetRepository(ref.watch(dbProvider))); + +class AssetRepository extends DatabaseRepository implements IAssetRepository { + AssetRepository(super.db); + + @override + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { + var query = album.assets.filter(); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); + } + if (ownerId != null) { + 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(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); + } + + return sortedQuery.findAll(); + } + + @override + Future deleteById(List ids) => txn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); + + @override + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); + + @override + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + 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(); + } + } + + @override + Future> getAll({ + required int ownerId, + AssetState? state, + AssetSort? sortBy, + 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 query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); + } + + return limit == null ? query.findAll() : query.limit(limit).findAll(); + } + + @override + Future> updateAll(List assets) async { + await txn(() => db.assets.putAll(assets)); + return assets; + } + + @override + Future> getMatches({ + required List assets, + required int ownerId, + AssetState? state, + 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(); + } + return _getMatchesImpl(query, ownerId, assets, limit); + } + + @override + Future> getDeviceAssetsById(List ids) => + Platform.isAndroid + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); + + @override + Future upsertDeviceAssets(List deviceAssets) => txn( + () => Platform.isAndroid + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ); + + @override + Future update(Asset asset) async { + await txn(() => asset.put(db)); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); +} + +Future> _getMatchesImpl( + QueryBuilder query, + int ownerId, + List assets, + int limit, +) => + query + .ownerIdEqualTo(ownerId) + .anyOf( + assets, + (q, Asset a) => q + .fileNameEqualTo(a.fileName) + .and() + .durationInSecondsEqualTo(a.durationInSeconds) + .and() + .fileCreatedAtBetween( + a.fileCreatedAt.subtract(const Duration(hours: 12)), + a.fileCreatedAt.add(const Duration(hours: 12)), + ) + .and() + .not() + .checksumEqualTo(a.checksum), + ) + .sortByFileName() + .thenByFileCreatedAt() + .thenByFileModifiedAt() + .limit(limit) + .findAll(); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart new file mode 100644 index 0000000000..54d57c4dfc --- /dev/null +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider).assetsApi, + ref.watch(apiServiceProvider).searchApi, + ), +); + +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { + final AssetsApi _api; + final SearchApi _searchApi; + + AssetApiRepository(this._api, this._searchApi); + + @override + Future update(String id, {String? description}) async { + final response = await checkNull( + _api.updateAsset(id, UpdateAssetDto(description: description)), + ); + return Asset.remote(response); + } + + @override + Future> search({List personIds = const []}) async { + // TODO this always fetches all assets, change API and usage to actually do pagination + final List result = []; + bool hasNext = true; + int currentPage = 1; + while (hasNext) { + final response = await checkNull( + _searchApi.searchMetadata( + MetadataSearchDto( + personIds: personIds, + page: currentPage, + size: 1000, + ), + ), + ); + result.addAll(response.assets.items.map(Asset.remote)); + hasNext = response.assets.nextPage != null; + currentPage++; + } + return result; + } +} diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart new file mode 100644 index 0000000000..68fffa08a6 --- /dev/null +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -0,0 +1,59 @@ +import 'package:hooks_riverpod/hooks_riverpod.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; + +final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository()); + +class AssetMediaRepository implements IAssetMediaRepository { + @override + Future> deleteAll(List ids) => + PhotoManager.editor.deleteWithIds(ids); + + @override + Future get(String id) async { + final entity = await AssetEntity.fromId(id); + return toAsset(entity); + } + + static Asset? toAsset(AssetEntity? local) { + if (local == null) return null; + final Asset asset = Asset( + checksum: "", + localId: local.id, + ownerId: Store.get(StoreKey.currentUser).isarId, + fileCreatedAt: local.createDateTime, + fileModifiedAt: local.modifiedDateTime, + updatedAt: local.modifiedDateTime, + durationInSeconds: local.duration, + type: AssetType.values[local.typeInt], + fileName: local.title!, + width: local.width, + height: local.height, + isFavorite: local.isFavorite, + ); + if (asset.fileCreatedAt.year == 1970) { + asset.fileCreatedAt = asset.fileModifiedAt; + } + if (local.latitude != null) { + asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); + } + asset.local = local; + return asset; + } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } +} diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart new file mode 100644 index 0000000000..61997ff23a --- /dev/null +++ b/mobile/lib/repositories/backup.repository.dart @@ -0,0 +1,42 @@ +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/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))); + +class BackupRepository extends DatabaseRepository implements IBackupRepository { + BackupRepository(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(); + } + return query.findAll(); + } + + @override + Future> getIdsBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); + + @override + Future updateAll(List backupAlbums) => + txn(() => db.backupAlbums.putAll(backupAlbums)); +} diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000..f9ee1426bb --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:isar/isar.dart'; + +/// copied from Isar; needed to check if an async transaction is already active +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { + final Isar db; + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000..5b42f66b02 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000..9921b69f5e --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DatabaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + txn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart new file mode 100644 index 0000000000..3ddb50104b --- /dev/null +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -0,0 +1,31 @@ +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/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart new file mode 100644 index 0000000000..15f7a51e15 --- /dev/null +++ b/mobile/lib/repositories/file_media.repository.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:photo_manager/photo_manager.dart' hide AssetType; + +final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository()); + +class FileMediaRepository implements IFileMediaRepository { + @override + Future saveImage( + Uint8List data, { + required String title, + String? relativePath, + }) async { + final entity = await PhotoManager.editor.saveImage( + data, + filename: title, + title: title, + relativePath: relativePath, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future saveImageWithFile( + String filePath, { + String? title, + String? relativePath, + }) async { + final entity = await PhotoManager.editor.saveImageWithPath( + filePath, + title: title, + relativePath: relativePath, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future saveLivePhoto({ + required File image, + required File video, + required String title, + }) async { + final entity = await PhotoManager.editor.darwin.saveLivePhoto( + imageFile: image, + videoFile: video, + title: title, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future saveVideo( + File file, { + required String title, + String? relativePath, + }) async { + final entity = await PhotoManager.editor.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future clearFileCache() => PhotoManager.clearFileCache(); + + @override + Future enableBackgroundAccess() => + PhotoManager.setIgnorePermissionCheck(true); + + @override + Future requestExtendedPermissions() => + PhotoManager.requestPermissionExtend(); +} diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart new file mode 100644 index 0000000000..1ae16d9d52 --- /dev/null +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final partnerApiRepositoryProvider = Provider( + (ref) => PartnerApiRepository( + ref.watch(apiServiceProvider).partnersApi, + ), +); + +class PartnerApiRepository extends ApiRepository + implements IPartnerApiRepository { + final PartnersApi _api; + + PartnerApiRepository(this._api); + + @override + Future> getAll(Direction direction) async { + final response = await checkNull( + _api.getPartners( + direction == Direction.sharedByMe + ? PartnerDirection.by + : PartnerDirection.with_, + ), + ); + return response.map(User.fromPartnerDto).toList(); + } + + @override + Future create(String id) async { + final dto = await checkNull(_api.createPartner(id)); + return User.fromPartnerDto(dto); + } + + @override + Future delete(String id) => _api.removePartner(id); + + @override + Future update(String id, {required bool inTimeline}) async { + final dto = await checkNull( + _api.updatePartner( + id, + UpdatePartnerDto(inTimeline: inTimeline), + ), + ); + return User.fromPartnerDto(dto); + } +} diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart new file mode 100644 index 0000000000..d324a03edb --- /dev/null +++ b/mobile/lib/repositories/person_api.repository.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final personApiRepositoryProvider = Provider( + (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), +); + +class PersonApiRepository extends ApiRepository + implements IPersonApiRepository { + final PeopleApi _api; + + PersonApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.getAllPeople()); + return dto.people.map(_toPerson).toList(); + } + + @override + Future update(String id, {String? name}) async { + final dto = await checkNull( + _api.updatePerson(id, PersonUpdateDto(name: name)), + ); + return _toPerson(dto); + } + + static Person _toPerson(PersonResponseDto dto) => Person( + birthDate: dto.birthDate, + id: dto.id, + isHidden: dto.isHidden, + name: dto.name, + thumbnailPath: dto.thumbnailPath, + ); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart new file mode 100644 index 0000000000..fb4df84fe7 --- /dev/null +++ b/mobile/lib/repositories/user.repository.dart @@ -0,0 +1,63 @@ +import 'package:hooks_riverpod/hooks_riverpod.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'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final userRepositoryProvider = + Provider((ref) => UserRepository(ref.watch(dbProvider))); + +class UserRepository extends DatabaseRepository implements IUserRepository { + UserRepository(super.db); + + @override + Future> getByIds(List ids) async => + (await db.users.getAllById(ids)).nonNulls.toList(); + + @override + Future get(String id) => db.users.getById(id); + + @override + Future> getAll({bool self = true, UserSort? sortBy}) { + final baseQuery = db.users.where(); + 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(); + } + return query.findAll(); + } + + @override + Future update(User user) async { + await txn(() => db.users.put(user)); + return user; + } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await txn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); +} diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart new file mode 100644 index 0000000000..9641c4e0e6 --- /dev/null +++ b/mobile/lib/repositories/user_api.repository.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final userApiRepositoryProvider = Provider( + (ref) => UserApiRepository( + ref.watch(apiServiceProvider).usersApi, + ), +); + +class UserApiRepository extends ApiRepository implements IUserApiRepository { + final UsersApi _api; + + UserApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.searchUsers()); + return dto.map(User.fromSimpleUserDto).toList(); + } + + @override + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }) async { + final response = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return (profileImagePath: response.profileImagePath); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3b28c73b27..b001c6bdd6 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -5,7 +5,6 @@ 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/albums/asset_selection_page_result.model.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'; @@ -14,6 +13,11 @@ 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/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/common/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/common/album_asset_selection.page.dart'; @@ -30,9 +34,9 @@ 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/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/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -48,12 +52,10 @@ 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/search/search_input.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link_edit.page.dart'; -import 'package:immich_mobile/pages/sharing/sharing.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/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -64,12 +66,11 @@ 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'; -import 'package:photo_manager/photo_manager.dart' hide LatLng; part 'router.gr.dart'; @AutoRouterConfig(replaceInRouteName: 'Page,Route') -class AppRouter extends _$AppRouter { +class AppRouter extends RootStackRouter { late final AuthGuard _authGuard; late final DuplicateGuard _duplicateGuard; late final BackupPermissionGuard _backupPermissionGuard; @@ -95,6 +96,11 @@ class AppRouter extends _$AppRouter { ), AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]), AutoRoute(page: ChangePasswordRoute.page), + AutoRoute( + page: SearchRoute.page, + guards: [_authGuard, _duplicateGuard], + maintainState: false, + ), CustomRoute( page: TabControllerRoute.page, guards: [_authGuard, _duplicateGuard], @@ -106,15 +112,16 @@ class AppRouter extends _$AppRouter { AutoRoute( page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], - ), - AutoRoute( - page: SharingRoute.page, - guards: [_authGuard, _duplicateGuard], + maintainState: false, ), AutoRoute( page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: AlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ], transitionsBuilder: TransitionsBuilders.fadeIn, ), @@ -137,7 +144,12 @@ class AppRouter extends _$AppRouter { ), AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), - AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: FilterImageRoute.page), + CustomRoute( + page: FavoritesRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( page: AllMotionPhotosRoute.page, @@ -183,8 +195,16 @@ class AppRouter extends _$AppRouter { AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), - AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: ArchiveRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PartnerRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], @@ -200,10 +220,15 @@ class AppRouter extends _$AppRouter { page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute( + CustomRoute( + page: TrashRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, ), AutoRoute( page: SharedLinkEditRoute.page, @@ -223,15 +248,30 @@ class AppRouter extends _$AppRouter { page: BackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - CustomRoute( - page: SearchInputRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.noTransition, - ), AutoRoute( page: HeaderSettingsRoute.page, guards: [_duplicateGuard], ), + CustomRoute( + page: PeopleCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: AlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: LocalAlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PlacesCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 77d031b5ed..ea7d385e85 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -9,379 +9,6 @@ part of 'router.dart'; -abstract class _$AppRouter extends RootStackRouter { - // ignore: unused_element - _$AppRouter({super.navigatorKey}); - - @override - final Map pagesMap = { - ActivitiesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ActivitiesPage(), - ); - }, - AlbumAdditionalSharedUserSelectionRoute.name: (routeData) { - final args = - routeData.argsAs(); - return AutoRoutePage?>( - routeData: routeData, - child: AlbumAdditionalSharedUserSelectionPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumAssetSelectionRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumAssetSelectionPage( - key: args.key, - existingAssets: args.existingAssets, - canDeselect: args.canDeselect, - query: args.query, - ), - ); - }, - AlbumOptionsRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumOptionsPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumPreviewRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumPreviewPage( - key: args.key, - album: args.album, - ), - ); - }, - AlbumSharedUserSelectionRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage>( - routeData: routeData, - child: AlbumSharedUserSelectionPage( - key: args.key, - assets: args.assets, - ), - ); - }, - AlbumViewerRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AlbumViewerPage( - key: args.key, - albumId: args.albumId, - ), - ); - }, - AllMotionPhotosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllMotionPhotosPage(), - ); - }, - AllPeopleRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllPeoplePage(), - ); - }, - AllPlacesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllPlacesPage(), - ); - }, - AllVideosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AllVideosPage(), - ); - }, - AppLogDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: AppLogDetailPage( - key: args.key, - logMessage: args.logMessage, - ), - ); - }, - AppLogRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const AppLogPage(), - ); - }, - ArchiveRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ArchivePage(), - ); - }, - BackupAlbumSelectionRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupAlbumSelectionPage(), - ); - }, - BackupControllerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupControllerPage(), - ); - }, - BackupOptionsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const BackupOptionsPage(), - ); - }, - ChangePasswordRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const ChangePasswordPage(), - ); - }, - CreateAlbumRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: CreateAlbumPage( - key: args.key, - isSharedAlbum: args.isSharedAlbum, - initialAssets: args.initialAssets, - ), - ); - }, - CropImageRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: CropImagePage( - key: args.key, - image: args.image, - ), - ); - }, - EditImageRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const EditImageRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: EditImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ), - ); - }, - FailedBackupStatusRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const FailedBackupStatusPage(), - ); - }, - FavoritesRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const FavoritesPage(), - ); - }, - GalleryViewerRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: GalleryViewerPage( - key: args.key, - renderList: args.renderList, - initialIndex: args.initialIndex, - heroOffset: args.heroOffset, - showStack: args.showStack, - ), - ); - }, - HeaderSettingsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const HeaderSettingsPage(), - ); - }, - LibraryRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const LibraryPage(), - ); - }, - LoginRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const LoginPage(), - ); - }, - MapLocationPickerRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const MapLocationPickerRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: MapLocationPickerPage( - key: args.key, - initialLatLng: args.initialLatLng, - ), - ); - }, - MapRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const MapPage(), - ); - }, - MemoryRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: MemoryPage( - memories: args.memories, - memoryIndex: args.memoryIndex, - key: args.key, - ), - ); - }, - PartnerDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: PartnerDetailPage( - key: args.key, - partner: args.partner, - ), - ); - }, - PartnerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PartnerPage(), - ); - }, - PermissionOnboardingRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PermissionOnboardingPage(), - ); - }, - PersonResultRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: PersonResultPage( - key: args.key, - personId: args.personId, - personName: args.personName, - ), - ); - }, - PhotosRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const PhotosPage(), - ); - }, - RecentlyAddedRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const RecentlyAddedPage(), - ); - }, - SearchInputRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const SearchInputRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: SearchInputPage( - key: args.key, - prefilter: args.prefilter, - ), - ); - }, - SearchRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SearchPage(), - ); - }, - SettingsRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SettingsPage(), - ); - }, - SettingsSubRoute.name: (routeData) { - final args = routeData.argsAs(); - return AutoRoutePage( - routeData: routeData, - child: SettingsSubPage( - args.section, - key: args.key, - ), - ); - }, - SharedLinkEditRoute.name: (routeData) { - final args = routeData.argsAs( - orElse: () => const SharedLinkEditRouteArgs()); - return AutoRoutePage( - routeData: routeData, - child: SharedLinkEditPage( - key: args.key, - existingLink: args.existingLink, - assetsList: args.assetsList, - albumId: args.albumId, - ), - ); - }, - SharedLinkRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SharedLinkPage(), - ); - }, - SharingRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SharingPage(), - ); - }, - SplashScreenRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const SplashScreenPage(), - ); - }, - TabControllerRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const TabControllerPage(), - ); - }, - TrashRoute.name: (routeData) { - return AutoRoutePage( - routeData: routeData, - child: const TrashPage(), - ); - }, - }; -} - /// generated route for /// [ActivitiesPage] class ActivitiesRoute extends PageRouteInfo { @@ -393,7 +20,12 @@ class ActivitiesRoute extends PageRouteInfo { static const String name = 'ActivitiesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ActivitiesPage(); + }, + ); } /// generated route for @@ -415,8 +47,16 @@ class AlbumAdditionalSharedUserSelectionRoute static const String name = 'AlbumAdditionalSharedUserSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumAdditionalSharedUserSelectionPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumAdditionalSharedUserSelectionRouteArgs { @@ -458,8 +98,18 @@ class AlbumAssetSelectionRoute static const String name = 'AlbumAssetSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumAssetSelectionPage( + key: args.key, + existingAssets: args.existingAssets, + canDeselect: args.canDeselect, + query: args.query, + ); + }, + ); } class AlbumAssetSelectionRouteArgs { @@ -502,8 +152,16 @@ class AlbumOptionsRoute extends PageRouteInfo { static const String name = 'AlbumOptionsRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumOptionsPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumOptionsRouteArgs { @@ -527,7 +185,7 @@ class AlbumOptionsRouteArgs { class AlbumPreviewRoute extends PageRouteInfo { AlbumPreviewRoute({ Key? key, - required AssetPathEntity album, + required Album album, List? children, }) : super( AlbumPreviewRoute.name, @@ -540,8 +198,16 @@ class AlbumPreviewRoute extends PageRouteInfo { static const String name = 'AlbumPreviewRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumPreviewPage( + key: args.key, + album: args.album, + ); + }, + ); } class AlbumPreviewRouteArgs { @@ -552,7 +218,7 @@ class AlbumPreviewRouteArgs { final Key? key; - final AssetPathEntity album; + final Album album; @override String toString() { @@ -579,8 +245,16 @@ class AlbumSharedUserSelectionRoute static const String name = 'AlbumSharedUserSelectionRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumSharedUserSelectionPage( + key: args.key, + assets: args.assets, + ); + }, + ); } class AlbumSharedUserSelectionRouteArgs { @@ -617,8 +291,16 @@ class AlbumViewerRoute extends PageRouteInfo { static const String name = 'AlbumViewerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AlbumViewerPage( + key: args.key, + albumId: args.albumId, + ); + }, + ); } class AlbumViewerRouteArgs { @@ -637,6 +319,25 @@ class AlbumViewerRouteArgs { } } +/// generated route for +/// [AlbumsPage] +class AlbumsRoute extends PageRouteInfo { + const AlbumsRoute({List? children}) + : super( + AlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'AlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AlbumsPage(); + }, + ); +} + /// generated route for /// [AllMotionPhotosPage] class AllMotionPhotosRoute extends PageRouteInfo { @@ -648,7 +349,12 @@ class AllMotionPhotosRoute extends PageRouteInfo { static const String name = 'AllMotionPhotosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllMotionPhotosPage(); + }, + ); } /// generated route for @@ -662,7 +368,12 @@ class AllPeopleRoute extends PageRouteInfo { static const String name = 'AllPeopleRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllPeoplePage(); + }, + ); } /// generated route for @@ -676,7 +387,12 @@ class AllPlacesRoute extends PageRouteInfo { static const String name = 'AllPlacesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllPlacesPage(); + }, + ); } /// generated route for @@ -690,7 +406,12 @@ class AllVideosRoute extends PageRouteInfo { static const String name = 'AllVideosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AllVideosPage(); + }, + ); } /// generated route for @@ -711,8 +432,16 @@ class AppLogDetailRoute extends PageRouteInfo { static const String name = 'AppLogDetailRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AppLogDetailPage( + key: args.key, + logMessage: args.logMessage, + ); + }, + ); } class AppLogDetailRouteArgs { @@ -742,7 +471,12 @@ class AppLogRoute extends PageRouteInfo { static const String name = 'AppLogRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AppLogPage(); + }, + ); } /// generated route for @@ -756,7 +490,12 @@ class ArchiveRoute extends PageRouteInfo { static const String name = 'ArchiveRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ArchivePage(); + }, + ); } /// generated route for @@ -770,7 +509,12 @@ class BackupAlbumSelectionRoute extends PageRouteInfo { static const String name = 'BackupAlbumSelectionRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupAlbumSelectionPage(); + }, + ); } /// generated route for @@ -784,7 +528,12 @@ class BackupControllerRoute extends PageRouteInfo { static const String name = 'BackupControllerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupControllerPage(); + }, + ); } /// generated route for @@ -798,7 +547,12 @@ class BackupOptionsRoute extends PageRouteInfo { static const String name = 'BackupOptionsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const BackupOptionsPage(); + }, + ); } /// generated route for @@ -812,7 +566,12 @@ class ChangePasswordRoute extends PageRouteInfo { static const String name = 'ChangePasswordRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const ChangePasswordPage(); + }, + ); } /// generated route for @@ -820,41 +579,45 @@ class ChangePasswordRoute extends PageRouteInfo { class CreateAlbumRoute extends PageRouteInfo { CreateAlbumRoute({ Key? key, - required bool isSharedAlbum, - List? initialAssets, + List? assets, List? children, }) : super( CreateAlbumRoute.name, args: CreateAlbumRouteArgs( key: key, - isSharedAlbum: isSharedAlbum, - initialAssets: initialAssets, + assets: assets, ), initialChildren: children, ); static const String name = 'CreateAlbumRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const CreateAlbumRouteArgs()); + return CreateAlbumPage( + key: args.key, + assets: args.assets, + ); + }, + ); } class CreateAlbumRouteArgs { const CreateAlbumRouteArgs({ this.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); final Key? key; - final bool isSharedAlbum; - - final List? initialAssets; + final List? assets; @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; + return 'CreateAlbumRouteArgs{key: $key, assets: $assets}'; } } @@ -864,35 +627,49 @@ class CropImageRoute extends PageRouteInfo { CropImageRoute({ Key? key, required Image image, + required Asset asset, List? children, }) : super( CropImageRoute.name, args: CropImageRouteArgs( key: key, image: image, + asset: asset, ), initialChildren: children, ); static const String name = 'CropImageRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return CropImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ); + }, + ); } class CropImageRouteArgs { const CropImageRouteArgs({ this.key, required this.image, + required this.asset, }); final Key? key; final Image image; + final Asset asset; + @override String toString() { - return 'CropImageRouteArgs{key: $key, image: $image}'; + return 'CropImageRouteArgs{key: $key, image: $image, asset: $asset}'; } } @@ -901,41 +678,56 @@ class CropImageRouteArgs { class EditImageRoute extends PageRouteInfo { EditImageRoute({ Key? key, - Image? image, - Asset? asset, + required Asset asset, + required Image image, + required bool isEdited, List? children, }) : super( EditImageRoute.name, args: EditImageRouteArgs( key: key, - image: image, asset: asset, + image: image, + isEdited: isEdited, ), initialChildren: children, ); static const String name = 'EditImageRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return EditImagePage( + key: args.key, + asset: args.asset, + image: args.image, + isEdited: args.isEdited, + ); + }, + ); } class EditImageRouteArgs { const EditImageRouteArgs({ this.key, - this.image, - this.asset, + required this.asset, + required this.image, + required this.isEdited, }); final Key? key; - final Image? image; + final Asset asset; - final Asset? asset; + final Image image; + + final bool isEdited; @override String toString() { - return 'EditImageRouteArgs{key: $key, image: $image, asset: $asset}'; + return 'EditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; } } @@ -950,7 +742,12 @@ class FailedBackupStatusRoute extends PageRouteInfo { static const String name = 'FailedBackupStatusRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const FailedBackupStatusPage(); + }, + ); } /// generated route for @@ -964,7 +761,64 @@ class FavoritesRoute extends PageRouteInfo { static const String name = 'FavoritesRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const FavoritesPage(); + }, + ); +} + +/// generated route for +/// [FilterImagePage] +class FilterImageRoute extends PageRouteInfo { + FilterImageRoute({ + Key? key, + required Image image, + required Asset asset, + List? children, + }) : super( + FilterImageRoute.name, + args: FilterImageRouteArgs( + key: key, + image: image, + asset: asset, + ), + initialChildren: children, + ); + + static const String name = 'FilterImageRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return FilterImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ); + }, + ); +} + +class FilterImageRouteArgs { + const FilterImageRouteArgs({ + this.key, + required this.image, + required this.asset, + }); + + final Key? key; + + final Image image; + + final Asset asset; + + @override + String toString() { + return 'FilterImageRouteArgs{key: $key, image: $image, asset: $asset}'; + } } /// generated route for @@ -991,8 +845,19 @@ class GalleryViewerRoute extends PageRouteInfo { static const String name = 'GalleryViewerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return GalleryViewerPage( + key: args.key, + renderList: args.renderList, + initialIndex: args.initialIndex, + heroOffset: args.heroOffset, + showStack: args.showStack, + ); + }, + ); } class GalleryViewerRouteArgs { @@ -1031,7 +896,12 @@ class HeaderSettingsRoute extends PageRouteInfo { static const String name = 'HeaderSettingsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const HeaderSettingsPage(); + }, + ); } /// generated route for @@ -1045,7 +915,31 @@ class LibraryRoute extends PageRouteInfo { static const String name = 'LibraryRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LibraryPage(); + }, + ); +} + +/// generated route for +/// [LocalAlbumsPage] +class LocalAlbumsRoute extends PageRouteInfo { + const LocalAlbumsRoute({List? children}) + : super( + LocalAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'LocalAlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LocalAlbumsPage(); + }, + ); } /// generated route for @@ -1059,7 +953,12 @@ class LoginRoute extends PageRouteInfo { static const String name = 'LoginRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LoginPage(); + }, + ); } /// generated route for @@ -1080,8 +979,17 @@ class MapLocationPickerRoute extends PageRouteInfo { static const String name = 'MapLocationPickerRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const MapLocationPickerRouteArgs()); + return MapLocationPickerPage( + key: args.key, + initialLatLng: args.initialLatLng, + ); + }, + ); } class MapLocationPickerRouteArgs { @@ -1111,7 +1019,12 @@ class MapRoute extends PageRouteInfo { static const String name = 'MapRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const MapPage(); + }, + ); } /// generated route for @@ -1134,7 +1047,17 @@ class MemoryRoute extends PageRouteInfo { static const String name = 'MemoryRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return MemoryPage( + memories: args.memories, + memoryIndex: args.memoryIndex, + key: args.key, + ); + }, + ); } class MemoryRouteArgs { @@ -1174,8 +1097,16 @@ class PartnerDetailRoute extends PageRouteInfo { static const String name = 'PartnerDetailRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return PartnerDetailPage( + key: args.key, + partner: args.partner, + ); + }, + ); } class PartnerDetailRouteArgs { @@ -1205,7 +1136,31 @@ class PartnerRoute extends PageRouteInfo { static const String name = 'PartnerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PartnerPage(); + }, + ); +} + +/// generated route for +/// [PeopleCollectionPage] +class PeopleCollectionRoute extends PageRouteInfo { + const PeopleCollectionRoute({List? children}) + : super( + PeopleCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PeopleCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PeopleCollectionPage(); + }, + ); } /// generated route for @@ -1219,7 +1174,12 @@ class PermissionOnboardingRoute extends PageRouteInfo { static const String name = 'PermissionOnboardingRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PermissionOnboardingPage(); + }, + ); } /// generated route for @@ -1242,8 +1202,17 @@ class PersonResultRoute extends PageRouteInfo { static const String name = 'PersonResultRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return PersonResultPage( + key: args.key, + personId: args.personId, + personName: args.personName, + ); + }, + ); } class PersonResultRouteArgs { @@ -1276,7 +1245,31 @@ class PhotosRoute extends PageRouteInfo { static const String name = 'PhotosRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PhotosPage(); + }, + ); +} + +/// generated route for +/// [PlacesCollectionPage] +class PlacesCollectionRoute extends PageRouteInfo { + const PlacesCollectionRoute({List? children}) + : super( + PlacesCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PlacesCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PlacesCollectionPage(); + }, + ); } /// generated route for @@ -1290,33 +1283,47 @@ class RecentlyAddedRoute extends PageRouteInfo { static const String name = 'RecentlyAddedRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const RecentlyAddedPage(); + }, + ); } /// generated route for -/// [SearchInputPage] -class SearchInputRoute extends PageRouteInfo { - SearchInputRoute({ +/// [SearchPage] +class SearchRoute extends PageRouteInfo { + SearchRoute({ Key? key, SearchFilter? prefilter, List? children, }) : super( - SearchInputRoute.name, - args: SearchInputRouteArgs( + SearchRoute.name, + args: SearchRouteArgs( key: key, prefilter: prefilter, ), initialChildren: children, ); - static const String name = 'SearchInputRoute'; + static const String name = 'SearchRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = + data.argsAs(orElse: () => const SearchRouteArgs()); + return SearchPage( + key: args.key, + prefilter: args.prefilter, + ); + }, + ); } -class SearchInputRouteArgs { - const SearchInputRouteArgs({ +class SearchRouteArgs { + const SearchRouteArgs({ this.key, this.prefilter, }); @@ -1327,24 +1334,10 @@ class SearchInputRouteArgs { @override String toString() { - return 'SearchInputRouteArgs{key: $key, prefilter: $prefilter}'; + return 'SearchRouteArgs{key: $key, prefilter: $prefilter}'; } } -/// generated route for -/// [SearchPage] -class SearchRoute extends PageRouteInfo { - const SearchRoute({List? children}) - : super( - SearchRoute.name, - initialChildren: children, - ); - - static const String name = 'SearchRoute'; - - static const PageInfo page = PageInfo(name); -} - /// generated route for /// [SettingsPage] class SettingsRoute extends PageRouteInfo { @@ -1356,7 +1349,12 @@ class SettingsRoute extends PageRouteInfo { static const String name = 'SettingsRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SettingsPage(); + }, + ); } /// generated route for @@ -1377,8 +1375,16 @@ class SettingsSubRoute extends PageRouteInfo { static const String name = 'SettingsSubRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return SettingsSubPage( + args.section, + key: args.key, + ); + }, + ); } class SettingsSubRouteArgs { @@ -1419,8 +1425,19 @@ class SharedLinkEditRoute extends PageRouteInfo { static const String name = 'SharedLinkEditRoute'; - static const PageInfo page = - PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const SharedLinkEditRouteArgs()); + return SharedLinkEditPage( + key: args.key, + existingLink: args.existingLink, + assetsList: args.assetsList, + albumId: args.albumId, + ); + }, + ); } class SharedLinkEditRouteArgs { @@ -1456,21 +1473,12 @@ class SharedLinkRoute extends PageRouteInfo { static const String name = 'SharedLinkRoute'; - static const PageInfo page = PageInfo(name); -} - -/// generated route for -/// [SharingPage] -class SharingRoute extends PageRouteInfo { - const SharingRoute({List? children}) - : super( - SharingRoute.name, - initialChildren: children, - ); - - static const String name = 'SharingRoute'; - - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SharedLinkPage(); + }, + ); } /// generated route for @@ -1484,7 +1492,12 @@ class SplashScreenRoute extends PageRouteInfo { static const String name = 'SplashScreenRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const SplashScreenPage(); + }, + ); } /// generated route for @@ -1498,7 +1511,12 @@ class TabControllerRoute extends PageRouteInfo { static const String name = 'TabControllerRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const TabControllerPage(); + }, + ); } /// generated route for @@ -1512,5 +1530,10 @@ class TrashRoute extends PageRouteInfo { static const String name = 'TrashRoute'; - static const PageInfo page = PageInfo(name); + static PageInfo page = PageInfo( + name, + builder: (data) { + return const TrashPage(); + }, + ); } diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index e16fecb323..7d96b83d02 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,8 @@ import 'package:auto_route/auto_route.dart'; 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/providers/memory.provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.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'; @@ -21,35 +17,11 @@ class TabNavigationObserver extends AutoRouterObserver { required this.ref, }); - @override - void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) { - // Perform tasks on first navigation to SearchRoute - if (route.name == 'SearchRoute') { - // ref.refresh(getCuratedLocationProvider); - } - } - @override Future didChangeTabRoute( TabPageRoute route, TabPageRoute previousRoute, ) async { - // Perform tasks on re-visit to SearchRoute - if (route.name == 'SearchRoute') { - // Refresh Location State - ref.invalidate(getPreviewPlacesProvider); - ref.invalidate(getAllPeopleProvider); - } - - if (route.name == 'SharingRoute') { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - Future(() => ref.read(assetProvider.notifier).getAllAsset()); - } - - if (route.name == 'LibraryRoute') { - ref.read(albumProvider.notifier).getAllAlbums(); - } - if (route.name == 'HomeRoute') { ref.invalidate(memoryFutureProvider); Future(() => ref.read(assetProvider.notifier).getAllAsset()); diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 58af26e204..5496041416 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,41 +1,31 @@ -import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; class ActivityService with ErrorLoggerMixin { - final ApiService _apiService; + final IActivityApiRepository _activityApiRepository; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._apiService); + ActivityService(this._activityApiRepository); Future> getAllActivities( String albumId, { String? assetId, }) async { return logError( - () async { - final list = await _apiService.activitiesApi - .getActivities(albumId, assetId: assetId); - return list != null ? list.map(Activity.fromDto).toList() : []; - }, + () => _activityApiRepository.getAll(albumId, assetId: assetId), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); } - Future getStatistics(String albumId, {String? assetId}) async { + Future getStatistics(String albumId, {String? assetId}) async { return logError( - () async { - final dto = await _apiService.activitiesApi - .getActivityStatistics(albumId, assetId: assetId); - return dto?.comments ?? 0; - }, - defaultValue: 0, + () => _activityApiRepository.getStats(albumId, assetId: assetId), + defaultValue: const ActivityStats(comments: 0), errorMessage: "Failed to statistics for album $albumId", ); } @@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin { Future removeActivity(String id) async { return logError( () async { - await _apiService.activitiesApi.deleteActivity(id); + await _activityApiRepository.delete(id); return true; }, defaultValue: false, @@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin { String? comment, }) async { return guardError( - () async { - final dto = await _apiService.activitiesApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }, + () => _activityApiRepository.create( + albumId, + type, + assetId: assetId, + comment: comment, + ), errorMessage: "Failed to create $type for album $albumId", ); } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index c2494680c7..53a65e2869 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,49 +5,63 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/services/backup.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/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.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/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'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; final albumServiceProvider = Provider( (ref) => AlbumService( - ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), - ref.watch(dbProvider), - ref.watch(backupServiceProvider), + ref.watch(entityServiceProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(backupRepositoryProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), ), ); class AlbumService { - final ApiService _apiService; final UserService _userService; final SyncService _syncService; - final Isar _db; - final BackupService _backupService; + final EntityService _entityService; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IBackupRepository _backupAlbumRepository; + final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( - this._apiService, this._userService, this._syncService, - this._db, - this._backupService, + this._entityService, + this._albumRepository, + this._assetRepository, + this._backupAlbumRepository, + this._albumMediaRepository, + this._albumApiRepository, ); /// Checks all selected device albums for changes of albums and their assets @@ -62,22 +76,18 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final List excludedIds = - await _backupService.excludedAlbumsQuery().idProperty().findAll(); - final List selectedIds = - await _backupService.selectedAlbumsQuery().idProperty().findAll(); + final List excludedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.exclude); + final List selectedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { - final numLocal = await _db.albums.where().localIdIsNotNull().count(); + final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } return false; } - final List onDevice = - await PhotoManager.getAssetPathList( - hasAll: true, - filterOption: FilterOptionGroup(containsPathModified: true), - ); + final List onDevice = await _albumMediaRepository.getAll(); _log.info("Found ${onDevice.length} device albums"); Set? excludedAssets; if (excludedIds.isNotEmpty) { @@ -93,13 +103,15 @@ class AlbumService { _log.info("Found ${excludedAssets.length} assets to exclude"); } // remove all excluded albums - onDevice.removeWhere((e) => excludedIds.contains(e.id)); + onDevice.removeWhere((e) => excludedIds.contains(e.localId)); _log.info( "Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums", ); } final hasAll = selectedIds - .map((id) => onDevice.firstWhereOrNull((a) => a.id == id)) + .map( + (id) => onDevice.firstWhereOrNull((album) => album.localId == id), + ) .whereNotNull() .any((a) => a.isAll); if (hasAll) { @@ -111,7 +123,7 @@ class AlbumService { } } else { // keep only the explicitly selected albums - onDevice.removeWhere((e) => !selectedIds.contains(e.id)); + onDevice.removeWhere((e) => !selectedIds.contains(e.localId)); _log.info("'Recents' is not selected, keeping only selected albums"); } changes = @@ -125,15 +137,15 @@ class AlbumService { } Future> _loadExcludedAssetIds( - List albums, + List albums, List excludedAlbumIds, ) async { final Set result = HashSet(); - for (AssetPathEntity a in albums) { - if (excludedAlbumIds.contains(a.id)) { - final List assets = - await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff); - result.addAll(assets.map((e) => e.id)); + for (Album album in albums) { + if (excludedAlbumIds.contains(album.localId)) { + final assetIds = + await _albumMediaRepository.getAssetIds(album.localId!); + result.addAll(assetIds); } } return result; @@ -141,7 +153,7 @@ class AlbumService { /// Checks remote albums (owned if `isShared` is false) for changes, /// updates the local database and returns `true` if there were any changes - Future refreshRemoteAlbums({required bool isShared}) async { + Future refreshRemoteAlbums() async { if (!_remoteCompleter.isCompleted) { // guard against concurrent calls return _remoteCompleter.future; @@ -151,18 +163,21 @@ class AlbumService { bool changes = false; try { await _userService.refreshUsers(); - final List? serverAlbums = await _apiService.albumsApi - .getAllAlbums(shared: isShared ? true : null); - if (serverAlbums == null) { - return false; - } - changes = await _syncService.syncRemoteAlbumsToDb( - serverAlbums, - isShared: isShared, - loadDetails: (dto) async => dto.assetCount == dto.assets.length - ? dto - : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto, + final List sharedAlbum = + await _albumApiRepository.getAll(shared: true); + + final List ownedAlbum = + await _albumApiRepository.getAll(shared: null); + + final albums = HashSet( + equals: (a, b) => a.remoteId == b.remoteId, + hashCode: (a) => a.remoteId.hashCode, ); + + albums.addAll(sharedAlbum); + albums.addAll(ownedAlbum); + + changes = await _syncService.syncRemoteAlbumsToDb(albums.toList()); } finally { _remoteCompleter.complete(changes); } @@ -175,30 +190,13 @@ class AlbumService { Iterable assets, [ Iterable sharedUsers = const [], ]) async { - try { - AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( - CreateAlbumDto( - albumName: albumName, - assetIds: assets.map((asset) => asset.remoteId!).toList(), - albumUsers: sharedUsers - .map( - (e) => AlbumUserCreateDto( - userId: e.id, - role: AlbumUserRole.editor, - ), - ) - .toList(), - ), - ); - if (remote != null) { - Album album = await Album.remote(remote); - await _db.writeTxn(() => _db.albums.store(album)); - return album; - } - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; + final Album album = await _albumApiRepository.create( + albumName, + assetIds: assets.map((asset) => asset.remoteId!), + sharedUserIds: sharedUsers.map((user) => user.id), + ); + await _entityService.fillAlbumWithDatabaseEntities(album); + return _albumRepository.create(album); } /* @@ -209,8 +207,7 @@ class AlbumService { for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (null == - await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } @@ -226,101 +223,55 @@ class AlbumService { ); } - Future addAdditionalAssetToAlbum( - Iterable assets, + Future addAssets( Album album, + Iterable assets, ) async { try { - var response = await _apiService.albumsApi.addAssetsToAlbum( + final result = await _albumApiRepository.addAssets( album.remoteId!, - BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - List successAssets = []; - List duplicatedAssets = []; + final List addedAssets = result.added + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)) + .toList(); - for (final result in response) { - if (result.success) { - successAssets - .add(assets.firstWhere((asset) => asset.remoteId == result.id)); - } else if (!result.success && - result.error == BulkIdResponseDtoErrorEnum.duplicate) { - duplicatedAssets.add(result.id); - } - } + await _updateAssets(album.id, add: addedAssets); - await _updateAssets(album.id, add: successAssets); - - return AlbumAddAssetsResponse( - alreadyInAlbum: duplicatedAssets, - successfullyAdded: successAssets.length, - ); - } + return AlbumAddAssetsResponse( + alreadyInAlbum: result.duplicates, + successfullyAdded: addedAssets.length, + ); } catch (e) { - debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); + debugPrint("Error addAssets ${e.toString()}"); } return null; } Future _updateAssets( int albumId, { - Iterable add = const [], - Iterable remove = const [], - }) { - return _db.writeTxn(() async { - final album = await _db.albums.get(albumId); - if (album == null) return; - await album.assets.update(link: add, unlink: remove); - album.startDate = - await album.assets.filter().fileCreatedAtProperty().min(); - album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); - album.lastModifiedAssetTimestamp = - await album.assets.filter().updatedAtProperty().max(); - await _db.albums.put(album); - }); - } + List add = const [], + List remove = const [], + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); - Future addAdditionalUserToAlbum( - List sharedUserIds, - Album album, - ) async { + Future setActivityStatus(Album album, bool enabled) async { try { - final List albumUsers = sharedUserIds - .map((userId) => AlbumUserAddDto(userId: userId)) - .toList(); - - final result = await _apiService.albumsApi.addUsersToAlbum( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, - AddUsersDto(albumUsers: albumUsers), + activityEnabled: enabled, ); - if (result != null) { - album.sharedUsers - .addAll((await _db.users.getAllById(sharedUserIds)).cast()); - album.shared = result.shared; - await _db.writeTxn(() async { - await _db.albums.put(album); - await album.sharedUsers.save(); - }); - return true; - } - } catch (e) { - debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); - } - return false; - } - - Future setActivityEnabled(Album album, bool enabled) async { - try { - final result = await _apiService.albumsApi.updateAlbumInfo( - album.remoteId!, - UpdateAlbumDto(isActivityEnabled: enabled), - ); - if (result != null) { - album.activityEnabled = enabled; - await _db.writeTxn(() => _db.albums.put(album)); - return true; - } + album.activityEnabled = updatedAlbum.activityEnabled; + await _albumRepository.update(album); + return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); } @@ -331,27 +282,27 @@ class AlbumService { try { final userId = Store.get(StoreKey.currentUser).isarId; if (album.owner.value?.isarId == userId) { - await _apiService.albumsApi.deleteAlbum(album.remoteId!); + await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); - await _db.writeTxn(() => _db.albums.delete(album.id)); - final List albums = - await _db.albums.filter().sharedEqualTo(true).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); + await _albumRepository.delete(album.id); + + final List albums = await _albumRepository.getAll(shared: true); final List existing = []; - for (Album a in albums) { + for (Album album in albums) { existing.addAll( - await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); + await _assetRepository.deleteById(idsToRemove); } } else { - await _db.writeTxn(() => _db.albums.delete(album.id)); + await _albumRepository.delete(album.id); } return true; } catch (e) { @@ -362,7 +313,7 @@ class AlbumService { Future leaveAlbum(Album album) async { try { - await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); + await _albumApiRepository.removeUser(album.remoteId!, userId: "me"); return true; } catch (e) { debugPrint("Error leaveAlbum ${e.toString()}"); @@ -370,75 +321,115 @@ class AlbumService { } } - Future removeAssetFromAlbum( + Future removeAsset( Album album, Iterable assets, ) async { try { - final response = await _apiService.albumsApi.removeAssetFromAlbum( + final result = await _albumApiRepository.removeAssets( album.remoteId!, - BulkIdsDto( - ids: assets.map((asset) => asset.remoteId!).toList(), - ), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - final toRemove = response.every((e) => e.success) - ? assets - : response - .where((e) => e.success) - .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove); - return true; - } + final toRemove = result.removed + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)); + await _updateAssets(album.id, remove: toRemove.toList()); + return true; } catch (e) { debugPrint("Error removeAssetFromAlbum ${e.toString()}"); } return false; } - Future removeUserFromAlbum( + Future removeUser( Album album, User user, ) async { try { - await _apiService.albumsApi.removeUserFromAlbum( + await _albumApiRepository.removeUser( album.remoteId!, - user.id, + userId: user.id, ); album.sharedUsers.remove(user); - await _db.writeTxn(() async { - await album.sharedUsers.update(unlink: [user]); - final a = await _db.albums.get(album.id); - // trigger watcher - await _db.albums.put(a!); - }); + await _albumRepository.removeUsers(album, [user]); + final a = await _albumRepository.get(album.id); + // trigger watcher + await _albumRepository.update(a!); return true; - } catch (e) { - debugPrint("Error removeUserFromAlbum ${e.toString()}"); + } catch (error) { + debugPrint("Error removeUser ${error.toString()}"); return false; } } + Future addUsers( + Album album, + List userIds, + ) async { + try { + final updatedAlbum = + await _albumApiRepository.addUsers(album.remoteId!, userIds); + + album.sharedUsers.addAll(updatedAlbum.remoteUsers); + album.shared = true; + + await _albumRepository.addUsers(album, album.sharedUsers.toList()); + await _albumRepository.update(album); + + return true; + } catch (error) { + debugPrint("Error addUsers ${error.toString()}"); + } + return false; + } + Future changeTitleAlbum( Album album, String newAlbumTitle, ) async { try { - await _apiService.albumsApi.updateAlbumInfo( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, - UpdateAlbumDto( - albumName: newAlbumTitle, - ), + name: newAlbumTitle, ); - album.name = newAlbumTitle; - await _db.writeTxn(() => _db.albums.put(album)); + album.name = updatedAlbum.name; + await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error changeTitleAlbum ${e.toString()}"); return false; } } + + Future getAlbumByName(String name, bool remoteOnly) => + _albumRepository.getByName(name, remote: remoteOnly ? true : null); + + /// + /// Add the uploaded asset to the selected albums + /// + Future syncUploadAlbums( + List albumNames, + List assetIds, + ) async { + for (final albumName in albumNames) { + Album? album = await getAlbumByName(albumName, true); + album ??= await createAlbum(albumName, []); + if (album != null && album.remoteId != null) { + await _albumApiRepository.addAssets(album.remoteId!, assetIds); + } + } + } + + Future> getAll() async { + return _albumRepository.getAll(remote: true); + } + + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + return _albumRepository.search(searchTerm, filterMode); + } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index c128a2c2fc..515023d163 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -18,7 +18,7 @@ class ApiService implements Authentication { late AlbumsApi albumsApi; late AssetsApi assetsApi; late SearchApi searchApi; - late ServerInfoApi serverInfoApi; + late ServerApi serverInfoApi; late MapApi mapApi; late PartnersApi partnersApi; late PeopleApi peopleApi; @@ -29,6 +29,7 @@ class ApiService implements Authentication { late ActivitiesApi activitiesApi; late DownloadApi downloadApi; late TrashApi trashApi; + late StacksApi stacksApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -49,7 +50,7 @@ class ApiService implements Authentication { oAuthApi = OAuthApi(_apiClient); albumsApi = AlbumsApi(_apiClient); assetsApi = AssetsApi(_apiClient); - serverInfoApi = ServerInfoApi(_apiClient); + serverInfoApi = ServerApi(_apiClient); searchApi = SearchApi(_apiClient); mapApi = MapApi(_apiClient); partnersApi = PartnersApi(_apiClient); @@ -61,6 +62,7 @@ class ApiService implements Authentication { activitiesApi = ActivitiesApi(_apiClient); downloadApi = DownloadApi(_apiClient); trashApi = TrashApi(_apiClient); + stacksApi = StacksApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { @@ -95,27 +97,13 @@ class ApiService implements Authentication { } Future _isEndpointAvailable(String serverUrl) async { - final Client client = Client(); - if (!serverUrl.endsWith('/api')) { serverUrl += '/api'; } try { - final response = await client - .get( - Uri.parse("$serverUrl/server-info/ping"), - headers: getRequestHeaders(), - ) - .timeout(const Duration(seconds: 5)); - - _log.info("Pinging server with response code ${response.statusCode}"); - if (response.statusCode != 200) { - _log.severe( - "Server Gateway Error: ${response.body} - Cannot communicate to the server", - ); - return false; - } + await setEndpoint(serverUrl); + await serverInfoApi.pingServer().timeout(Duration(seconds: 5)); } on TimeoutException catch (_) { return false; } on SocketException catch (_) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index fd6c2d89a7..8f773e1bb3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { @@ -8,6 +9,21 @@ enum AppSettingsEnum { "themeMode", "system", ), // "light","dark","system" + primaryColor( + StoreKey.primaryColor, + "primaryColor", + defaultColorPresetName, + ), + dynamicTheme( + StoreKey.dynamicTheme, + "dynamicTheme", + false, + ), + colorfulInterface( + StoreKey.colorfulInterface, + "colorfulInterface", + true, + ), tilesPerRow(StoreKey.tilesPerRow, "tilesPerRow", 4), dynamicLayout(StoreKey.dynamicLayout, "dynamicLayout", false), groupAssetsBy(StoreKey.groupAssetsBy, "groupBy", 0), @@ -60,6 +76,7 @@ enum AppSettingsEnum { false, ), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), + syncAlbums(StoreKey.syncAlbums, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 5751c00b47..b2cad4dc82 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,56 +1,85 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/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/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/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.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'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( + ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), - ref.watch(dbProvider), + ref.watch(backupServiceProvider), + ref.watch(albumServiceProvider), ), ); class AssetService { + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; + final BackupService _backupService; + final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( + this._assetApiRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, - this._db, + this._backupService, + this._albumService, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -155,16 +184,17 @@ class AssetService { /// 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 _db.exifInfos.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); + a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -193,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -209,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -229,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -257,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -283,4 +313,93 @@ class AssetService { return Future.value(null); } } + + Future syncUploadedAssetToAlbums() async { + try { + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); + + final candidates = await _backupService.buildUploadCandidates( + selectedAlbums, + excludedAlbums, + useTimeFilter: false, + ); + + await refreshRemoteAssets(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); + + /// Map + Map> assetToAlbums = {}; + + for (BackupCandidate candidate in candidates) { + final asset = remoteAssets.firstWhereOrNull( + (a) => a.localId == candidate.asset.localId, + ); + + if (asset != null) { + for (final albumName in candidate.albumNames) { + assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!); + } + } + } + + // Upload assets to albums + for (final entry in assetToAlbums.entries) { + final albumName = entry.key; + final assetIds = entry.value; + + await _albumService.syncUploadAlbums([albumName], assetIds); + } + } catch (error, stack) { + log.severe("Error while syncing uploaded asset to albums", error, stack); + } + } + + Future setDescription( + Asset asset, + String newDescription, + ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + final result = await _assetApiRepository.update( + remoteAssetId, + description: newDescription, + ); + + final description = result.exifInfo?.description; + + if (description != null) { + var exifInfo = await _exifInfoRepository.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = description; + await _exifInfoRepository.update(exifInfo); + } + } + } + + Future getDescription(Asset asset) async { + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (localExifId == null) { + return ""; + } + + final exifInfo = await _exifInfoRepository.get(localExifId); + + return exifInfo?.description ?? ""; + } } diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart deleted file mode 100644 index 66437d61e2..0000000000 --- a/mobile/lib/services/asset_description.service.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.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:openapi/api.dart'; - -class AssetDescriptionService { - AssetDescriptionService(this._db, this._api); - - final Isar _db; - final ApiService _api; - - Future setDescription( - Asset asset, - String newDescription, - ) async { - final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - final result = await _api.assetsApi.updateAsset( - remoteAssetId, - UpdateAssetDto(description: newDescription), - ); - - final description = result?.exifInfo?.description; - - if (description != null) { - var exifInfo = await _db.exifInfos.get(localExifId); - - if (exifInfo != null) { - exifInfo.description = description; - await _db.writeTxn( - () => _db.exifInfos.put(exifInfo), - ); - } - } - } -} - -final assetDescriptionServiceProvider = Provider( - (ref) => AssetDescriptionService( - ref.watch(dbProvider), - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/asset_stack.service.dart b/mobile/lib/services/asset_stack.service.dart deleted file mode 100644 index 9eff495f37..0000000000 --- a/mobile/lib/services/asset_stack.service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:openapi/api.dart'; - -class AssetStackService { - AssetStackService(this._api); - - final ApiService _api; - - Future updateStack( - Asset parentAsset, { - List? childrenToAdd, - List? childrenToRemove, - }) async { - // Guard [local asset] - if (parentAsset.remoteId == null) { - return; - } - - try { - if (childrenToAdd != null) { - final toAdd = childrenToAdd - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), - ); - } - - if (childrenToRemove != null) { - final toRemove = childrenToRemove - .where((e) => e.isRemote) - .map((e) => e.remoteId!) - .toList(); - await _api.assetsApi.updateAssets( - AssetBulkUpdateDto(ids: toRemove, removeParent: true), - ); - } - } catch (error) { - debugPrint("Error while updating stack children: ${error.toString()}"); - } - } - - Future updateStackParent(Asset oldParent, Asset newParent) async { - // Guard [local asset] - if (oldParent.remoteId == null || newParent.remoteId == null) { - return; - } - - try { - await _api.assetsApi.updateStackParent( - UpdateStackParentDto( - oldParentId: oldParent.remoteId!, - newParentId: newParent.remoteId!, - ), - ); - } catch (error) { - debugPrint("Error while updating stack parent: ${error.toString()}"); - } - } -} - -final assetStackServiceProvider = Provider( - (ref) => AssetStackService( - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index ba8f5c01ed..3959e2a6ed 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,7 +9,25 @@ import 'package:easy_localization/easy_localization.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/models/backup/backup_candidate.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/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.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/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/services/album.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'; @@ -18,12 +36,13 @@ 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/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backgroundServiceProvider = Provider( (ref) => BackgroundService(), @@ -340,21 +359,78 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); + HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); - BackupService backupService = BackupService(apiService, db, settingService); AppSettingsService settingsService = AppSettingsService(); + AlbumRepository albumRepository = AlbumRepository(db); + AssetRepository assetRepository = AssetRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); + AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); + FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); + UserRepository userRepository = UserRepository(db); + UserApiRepository userApiRepository = + UserApiRepository(apiService.usersApi); + AlbumApiRepository albumApiRepository = + AlbumApiRepository(apiService.albumsApi); + PartnerApiRepository partnerApiRepository = + PartnerApiRepository(apiService.partnersApi); + HashService hashService = + HashService(assetRepository, this, albumMediaRepository); + EntityService entityService = + EntityService(assetRepository, userRepository); + SyncService syncSerive = SyncService( + hashService, + entityService, + albumMediaRepository, + albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + UserService userService = UserService( + partnerApiRepository, + userApiRepository, + userRepository, + syncSerive, + ); + AlbumService albumService = AlbumService( + userService, + syncSerive, + entityService, + albumRepository, + assetRepository, + backupRepository, + albumMediaRepository, + albumApiRepository, + ); + BackupService backupService = BackupService( + apiService, + settingService, + albumService, + albumMediaRepository, + fileMediaRepository, + assetRepository, + assetMediaRepository, + ); - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } - await PhotoManager.setIgnorePermissionCheck(true); + await fileMediaRepository.enableBackgroundAccess(); do { final bool backupOk = await _runBackup( @@ -367,28 +443,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; @@ -416,7 +492,7 @@ class BackgroundService { return false; } - List toUpload = await backupService.buildUploadCandidates( + Set toUpload = await backupService.buildUploadCandidates( selectedAlbums, excludedAlbums, ); @@ -460,29 +536,47 @@ class BackgroundService { final bool ok = await backupService.backupAsset( toUpload, _cancellationToken!, - pmProgressHandler, - notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {}, - notifySingleProgress ? _onProgress : (sent, total) {}, - notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {}, - _onBackupError, - sortAssets: true, + pmProgressHandler: pmProgressHandler, + onSuccess: (result) => _onAssetUploaded( + result: result, + shouldNotify: notifyTotalProgress, + ), + onProgress: (bytes, totalBytes) => + _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress), + onCurrentAsset: (asset) => + _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress), + onError: _onBackupError, + isBackground: true, ); + if (!ok && !_cancellationToken!.isCancelled) { _showErrorNotification( title: "backup_background_service_error_title".tr(), content: "backup_background_service_backup_failed_message".tr(), ); } + return ok; } - void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) { + void _onAssetUploaded({ + required SuccessUploadAsset result, + bool shouldNotify = false, + }) async { + if (!shouldNotify) { + return; + } + _uploadedAssetsCount++; _throttledNotifiy(); } - void _onProgress(int sent, int total) { - _throttledDetailNotify(progress: sent, total: total); + void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) { + if (!shouldNotify) { + return; + } + + _throttledDetailNotify(progress: bytes, total: totalBytes); } void _updateDetailProgress(String? title, int progress, int total) { @@ -522,7 +616,14 @@ class BackgroundService { ); } - void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { + void _onSetCurrentBackupAsset( + CurrentUploadAsset currentUploadAsset, { + bool shouldNotify = false, + }) { + if (!shouldNotify) { + return; + } + _throttledDetailNotify.title = "backup_background_service_current_upload_notification" .tr(args: [currentUploadAsset.fileName]); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index a42c587435..a0b6bf16c2 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -6,39 +6,65 @@ 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/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/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.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/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.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/file_media.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:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; -import 'package:permission_handler/permission_handler.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:permission_handler/permission_handler.dart' as pm; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), + ref.watch(albumServiceProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; + final AlbumService _albumService; + final IAlbumMediaRepository _albumMediaRepository; + final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IAssetMediaRepository _assetMediaRepository; - BackupService(this._apiService, this._db, this._appSetting); + BackupService( + this._apiService, + this._appSetting, + this._albumService, + this._albumMediaRepository, + this._fileMediaRepository, + this._assetRepository, + this._assetMediaRepository, + ); Future?> getDeviceBackupAsset() async { final String deviceId = Store.get(StoreKey.deviceId); @@ -51,136 +77,125 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album - Future> buildUploadCandidates( + /// if `useTimeFilter` is set to true, all assets will be returned + Future> buildUploadCandidates( List selectedBackupAlbums, - List excludedBackupAlbums, - ) async { - final filter = FilterOptionGroup( - containsPathModified: true, - orders: [const OrderOption(type: OrderOptionType.updateDate)], - // title is needed to create Assets - imageOption: const FilterOption(needTitle: true), - videoOption: const FilterOption(needTitle: true), - ); + List excludedBackupAlbums, { + bool useTimeFilter = true, + }) async { final now = DateTime.now(); - final List selectedAlbums = - await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now); - if (selectedAlbums.every((e) => e == null)) { - return []; - } - final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll); - if (allIdx != -1) { - final List excludedAlbums = - await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now); - final List toAdd = await _fetchAssetsAndUpdateLastBackup( - selectedAlbums.slice(allIdx, allIdx + 1), - selectedBackupAlbums.slice(allIdx, allIdx + 1), - now, - ); - final List toRemove = await _fetchAssetsAndUpdateLastBackup( - excludedAlbums, - excludedBackupAlbums, - now, - ); - return toAdd.toSet().difference(toRemove.toSet()).toList(); - } else { - return await _fetchAssetsAndUpdateLastBackup( - selectedAlbums, - selectedBackupAlbums, - now, - ); - } + + final Set toAdd = await _fetchAssetsAndUpdateLastBackup( + selectedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + if (toAdd.isEmpty) return {}; + + final Set toRemove = await _fetchAssetsAndUpdateLastBackup( + excludedBackupAlbums, + now, + useTimeFilter: useTimeFilter, + ); + + return toAdd.difference(toRemove); } - Future> _loadAlbumsWithTimeFilter( - List albums, - FilterOptionGroup filter, - DateTime now, - ) async { - List result = []; - for (BackupAlbum a in albums) { + Future> _fetchAssetsAndUpdateLastBackup( + List backupAlbums, + DateTime now, { + bool useTimeFilter = true, + }) async { + Set candidates = {}; + + for (final BackupAlbum backupAlbum in backupAlbums) { + final Album localAlbum; try { - final AssetPathEntity album = - await AssetPathEntity.obtainPathFromProperties( - id: a.id, - optionGroup: filter.copyWith( - updateTimeCond: DateTimeCond( + localAlbum = await _albumMediaRepository.get(backupAlbum.id); + } on StateError { + // the album no longer exists + continue; + } + + if (useTimeFilter && + localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) { + continue; + } + final List assets; + try { + assets = await _albumMediaRepository.getAssets( + backupAlbum.id, + modifiedFrom: useTimeFilter + ? // subtract 2 seconds to prevent missing assets due to rounding issues - min: a.lastBackup.subtract(const Duration(seconds: 2)), - max: now, - ), - ), - maxDateTimeToNow: false, + backupAlbum.lastBackup.subtract(const Duration(seconds: 2)) + : null, + modifiedUntil: useTimeFilter ? now : null, ); - result.add(album); } on StateError { // either there are no assets matching the filter criteria OR the album no longer exists + continue; } - } - return result; - } - Future> _fetchAssetsAndUpdateLastBackup( - List albums, - List backupAlbums, - DateTime now, - ) async { - List result = []; - for (int i = 0; i < albums.length; i++) { - final AssetPathEntity? a = albums[i]; - if (a != null && - a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) { - result.addAll( - await a.getAssetListRange(start: 0, end: await a.assetCountAsync), + // Add album's name to the asset info + for (final asset in assets) { + List albumNames = [localAlbum.name]; + + final existingAsset = candidates.firstWhereOrNull( + (candidate) => candidate.asset.localId == asset.localId, ); - backupAlbums[i].lastBackup = now; + + if (existingAsset != null) { + albumNames.addAll(existingAsset.albumNames); + candidates.remove(existingAsset); + } + + candidates.add(BackupCandidate(asset: asset, albumNames: albumNames)); } + + backupAlbum.lastBackup = now; } - return result; + + return candidates; } /// Returns a new list of assets not yet uploaded - Future> removeAlreadyUploadedAssets( - List candidates, + Future> removeAlreadyUploadedAssets( + Set candidates, ) async { if (candidates.isEmpty) { return candidates; } + final Set duplicatedAssetIds = await getDuplicatedAssetIds(); - candidates = duplicatedAssetIds.isEmpty - ? candidates - : candidates - .whereNot((asset) => duplicatedAssetIds.contains(asset.id)) - .toList(); + candidates.removeWhere( + (candidate) => duplicatedAssetIds.contains(candidate.asset.localId), + ); + if (candidates.isEmpty) { return candidates; } + final Set existing = {}; try { final String deviceId = Store.get(StoreKey.deviceId); final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( - deviceAssetIds: candidates.map((e) => e.id).toList(), + deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(), deviceId: deviceId, ), ); @@ -194,61 +209,81 @@ class BackupService { existing.addAll(allAssetsInDatabase); } } - return existing.isEmpty - ? candidates - : candidates.whereNot((e) => existing.contains(e.id)).toList(); + + if (existing.isNotEmpty) { + candidates.removeWhere((c) => existing.contains(c.asset.localId)); + } + + return candidates; } - Future backupAsset( - Iterable assetList, - http.CancellationToken cancelToken, - PMProgressHandler? pmProgressHandler, - Function(String, String, bool) uploadSuccessCb, - Function(int, int) uploadProgressCb, - Function(CurrentUploadAsset) setCurrentUploadAssetCb, - Function(ErrorUploadAsset) errorCb, { - bool sortAssets = false, - }) async { - final bool isIgnoreIcloudAssets = - _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); - + Future _checkPermissions() async { if (Platform.isAndroid && - !(await Permission.accessMediaLocation.status).isGranted) { + !(await pm.Permission.accessMediaLocation.status).isGranted) { // double check that permission is granted here, to guard against // uploading corrupt assets without EXIF information _log.warning("Media location permission is not granted. " "Cannot access original assets for backup."); + return false; } - final String deviceId = Store.get(StoreKey.deviceId); - final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - bool anyErrors = false; - final List duplicatedAssetIds = []; // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { - await PhotoManager.requestPermissionExtend(); + await _fileMediaRepository.requestExtendedPermissions(); } - List assetsToUpload = sortAssets - // Upload images before video assets - // these are further sorted by using their creation date - ? assetList.sorted( - (a, b) { - final cmp = a.typeInt - b.typeInt; - if (cmp != 0) return cmp; - return a.createDateTime.compareTo(b.createDateTime); - }, - ) - : assetList.toList(); + return true; + } - for (var entity in assetsToUpload) { + /// Upload images before video assets for background tasks + /// these are further sorted by using their creation date + List _sortPhotosFirst(List candidates) { + return candidates.sorted( + (a, b) { + final cmp = a.asset.type.index - b.asset.type.index; + if (cmp != 0) return cmp; + return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt); + }, + ); + } + + Future backupAsset( + Iterable assets, + http.CancellationToken cancelToken, { + bool isBackground = false, + PMProgressHandler? pmProgressHandler, + required void Function(SuccessUploadAsset result) onSuccess, + required void Function(int bytes, int totalBytes) onProgress, + required void Function(CurrentUploadAsset asset) onCurrentAsset, + required void Function(ErrorUploadAsset error) onError, + }) async { + final bool isIgnoreIcloudAssets = + _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets); + final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums); + final String deviceId = Store.get(StoreKey.deviceId); + final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final List duplicatedAssetIds = []; + bool anyErrors = false; + + final hasPermission = await _checkPermissions(); + if (!hasPermission) { + return false; + } + + List candidates = assets.toList(); + if (isBackground) { + candidates = _sortPhotosFirst(candidates); + } + + for (final candidate in candidates) { + final Asset asset = candidate.asset; File? file; File? livePhotoFile; try { final isAvailableLocally = - await entity.isLocallyAvailable(isOrigin: true); + await asset.local!.isLocallyAvailable(isOrigin: true); // Handle getting files from iCloud if (!isAvailableLocally && Platform.isIOS) { @@ -257,41 +292,45 @@ class BackupService { continue; } - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( - id: entity.id, - fileCreatedAt: entity.createDateTime.year == 1970 - ? entity.modifiedDateTime - : entity.createDateTime, - fileName: await entity.titleAsync, - fileType: _getAssetType(entity.type), + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, + fileName: asset.fileName, + fileType: _getAssetType(asset.type), iCloudAsset: true, ), ); - file = await entity.loadFile(progressHandler: pmProgressHandler); - if (entity.isLivePhoto) { - livePhotoFile = await entity.loadFile( + file = + await asset.local!.loadFile(progressHandler: pmProgressHandler); + if (asset.local!.isLivePhoto) { + livePhotoFile = await asset.local!.loadFile( withSubtype: true, progressHandler: pmProgressHandler, ); } } else { - if (entity.type == AssetType.video) { - file = await entity.originFile; + if (asset.type == AssetType.video) { + file = await asset.local!.originFile; } else { - file = await entity.originFile.timeout(const Duration(seconds: 5)); - if (entity.isLivePhoto) { - livePhotoFile = await entity.originFileWithSubtype + file = await asset.local!.originFile + .timeout(const Duration(seconds: 5)); + if (asset.local!.isLivePhoto) { + livePhotoFile = await asset.local!.originFileWithSubtype .timeout(const Duration(seconds: 5)); } } } if (file != null) { - String originalFileName = await entity.titleAsync; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; - if (entity.isLivePhoto) { + if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { _log.warning( "Failed to obtain motion part of the livePhoto - $originalFileName", @@ -299,51 +338,47 @@ class BackupService { } } - var fileStream = file.openRead(); - var assetRawUploadData = http.MultipartFile( + final fileStream = file.openRead(); + final assetRawUploadData = http.MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - var baseRequest = MultipartRequest( + final baseRequest = MultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => - uploadProgressCb(bytes, totalBytes)), + onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); + baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; - - baseRequest.fields['deviceAssetId'] = entity.id; + baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = - entity.createDateTime.toUtc().toIso8601String(); + asset.fileCreatedAt.toUtc().toIso8601String(); baseRequest.fields['fileModifiedAt'] = - entity.modifiedDateTime.toUtc().toIso8601String(); - baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); - baseRequest.fields['duration'] = entity.videoDuration.toString(); - + asset.fileModifiedAt.toUtc().toIso8601String(); + baseRequest.fields['isFavorite'] = asset.isFavorite.toString(); + baseRequest.fields['duration'] = asset.duration.toString(); baseRequest.files.add(assetRawUploadData); - var fileSize = file.lengthSync(); - - setCurrentUploadAssetCb( + onCurrentAsset( CurrentUploadAsset( - id: entity.id, - fileCreatedAt: entity.createDateTime.year == 1970 - ? entity.modifiedDateTime - : entity.createDateTime, + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, fileName: originalFileName, - fileType: _getAssetType(entity.type), - fileSize: fileSize, + fileType: _getAssetType(asset.type), + fileSize: file.lengthSync(), iCloudAsset: false, ), ); String? livePhotoVideoId; - if (entity.isLivePhoto && livePhotoFile != null) { + if (asset.local!.isLivePhoto && livePhotoFile != null) { livePhotoVideoId = await uploadLivePhotoVideo( originalFileName, livePhotoFile, @@ -356,28 +391,29 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - var response = await httpClient.send( + final response = await httpClient.send( baseRequest, cancellationToken: cancelToken, ); - var responseBody = jsonDecode(await response.stream.bytesToString()); + final responseBody = + jsonDecode(await response.stream.bytesToString()); if (![200, 201].contains(response.statusCode)) { - var error = responseBody; - var errorMessage = error['message'] ?? error['error']; + final error = responseBody; + final errorMessage = error['message'] ?? error['error']; debugPrint( - "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", + "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", ); - errorCb( + onError( ErrorUploadAsset( - asset: entity, - id: entity.id, - fileCreatedAt: entity.createDateTime, + asset: asset, + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt, fileName: originalFileName, - fileType: _getAssetType(entity.type), + fileType: _getAssetType(candidate.asset.type), errorMessage: errorMessage, ), ); @@ -386,23 +422,37 @@ class BackupService { anyErrors = true; break; } + continue; } - var isDuplicate = false; + bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; - duplicatedAssetIds.add(entity.id); + duplicatedAssetIds.add(asset.localId!); } - uploadSuccessCb(entity.id, deviceId, isDuplicate); + onSuccess( + SuccessUploadAsset( + candidate: candidate, + remoteAssetId: responseBody['id'] as String, + isDuplicate: isDuplicate, + ), + ); + + if (shouldSyncAlbums) { + await _albumService.syncUploadAlbums( + candidate.albumNames, + [responseBody['id'] as String], + ); + } } } on http.CancelledException { debugPrint("Backup was cancelled by the user"); anyErrors = true; break; - } catch (e) { - debugPrint("ERROR backupAsset: ${e.toString()}"); + } catch (error, stackTrace) { + debugPrint("Error backup asset: ${error.toString()}: $stackTrace"); anyErrors = true; continue; } finally { @@ -416,9 +466,11 @@ class BackupService { } } } + if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } + return !anyErrors; } diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index c7cd134cb1..82cfb8347a 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -8,39 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.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/providers/db.provider.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/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/diff.dart'; -import 'package:isar/isar.dart'; -import 'package:photo_manager/photo_manager.dart' show PhotoManager; /// Finds duplicates originating from missing EXIF information class BackupVerificationService { - final Isar _db; + final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; - BackupVerificationService(this._db); + BackupVerificationService( + this._fileMediaRepository, + this._assetRepository, + this._exifInfoRepository, + ); /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { final owner = Store.get(StoreKey.currentUser).isarId; - final List onlyLocal = await _db.assets - .where() - .remoteIdIsNull() - .filter() - .ownerIdEqualTo(owner) - .localIdIsNotNull() - .findAll(); - final List remoteMatches = await _getMatches( - _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(), - owner, - onlyLocal, - limit, + final List onlyLocal = await _assetRepository.getAll( + ownerId: owner, + state: AssetState.local, + limit: limit, ); - final List localMatches = await _getMatches( - _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), - owner, - remoteMatches, - limit, + final List remoteMatches = await _assetRepository.getMatches( + assets: onlyLocal, + ownerId: owner, + state: AssetState.remote, + limit: limit, + ); + final List localMatches = await _assetRepository.getMatches( + assets: remoteMatches, + ownerId: owner, + state: AssetState.local, + limit: limit, ); final List deleteCandidates = [], originals = []; @@ -50,7 +57,7 @@ class BackupVerificationService { localMatches, compare: (a, b) => a.fileName.compareTo(b.fileName), both: (a, b) async { - a.exifInfo = await _db.exifInfos.get(a.id); + a.exifInfo = await _exifInfoRepository.get(a.id); deleteCandidates.add(a); originals.add(b); return false; @@ -71,6 +78,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); final upper = compute( @@ -81,6 +89,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); toDelete = await lower + await upper; @@ -93,6 +102,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); } @@ -106,12 +116,13 @@ class BackupVerificationService { String auth, String endpoint, RootIsolateToken rootIsolateToken, + IFileMediaRepository fileMediaRepository, }) tuple, ) async { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); - await PhotoManager.setIgnorePermissionCheck(true); + await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); apiService.setAccessToken(tuple.auth); @@ -186,35 +197,6 @@ class BackupVerificationService { return bytes.buffer.asUint64List(start); } - static Future> _getMatches( - QueryBuilder query, - int ownerId, - List assets, - int limit, - ) => - query - .ownerIdEqualTo(ownerId) - .anyOf( - assets, - (q, Asset a) => q - .fileNameEqualTo(a.fileName) - .and() - .durationInSecondsEqualTo(a.durationInSeconds) - .and() - .fileCreatedAtBetween( - a.fileCreatedAt.subtract(const Duration(hours: 12)), - a.fileCreatedAt.add(const Duration(hours: 12)), - ) - .and() - .not() - .checksumEqualTo(a.checksum), - ) - .sortByFileName() - .thenByFileCreatedAt() - .thenByFileModifiedAt() - .limit(limit) - .findAll(); - static bool _sameExceptTimeZone(DateTime a, DateTime b) { final ms = a.isAfter(b) ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch @@ -227,6 +209,8 @@ class BackupVerificationService { final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( - ref.watch(dbProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ), ); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000..7cf6f309e9 --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,230 @@ +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/entities/asset.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'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; +import 'package:logging/logging.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + final Logger _log = Logger("DownloadService"); + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImageWithPath(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + try { + final Asset? resultAsset = await _fileMediaRepository.saveImageWithFile( + filePath, + title: title, + relativePath: relativePath, + ); + return resultAsset != null; + } catch (error, stack) { + _log.severe("Error saving image", error, stack); + return false; + } finally { + if (await File(filePath).exists()) { + await File(filePath).delete(); + } + } + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + try { + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + return resultAsset != null; + } catch (error, stack) { + _log.severe("Error saving video", error, stack); + return false; + } finally { + if (await file.exists()) { + await file.delete(); + } + } + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = + _findTaskRecord(records, livePhotosId, LivePhotosPart.image); + final videoRecord = + _findTaskRecord(records, livePhotosId, LivePhotosPart.video); + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + try { + final result = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + return result != null; + } on PlatformException catch (error, stack) { + // Handle saving MotionPhotos on iOS + if (error.code == 'PHPhotosErrorDomain (-1)') { + final result = await _fileMediaRepository + .saveImageWithFile(imageFilePath, title: task.filename); + return result != null; + } + _log.severe("Error saving live photo", error, stack); + return false; + } catch (error, stack) { + _log.severe("Error saving live photo", error, stack); + return false; + } finally { + final imageFile = File(imageFilePath); + if (await imageFile.exists()) { + await imageFile.delete(); + } + + final videoFile = File(videoFilePath); + if (await videoFile.exists()) { + await videoFile.delete(); + } + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName + .toUpperCase() + .replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} + +TaskRecord _findTaskRecord( + List records, + String livePhotosId, + LivePhotosPart part, +) { + return records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && metadata.part == part; + }); +} diff --git a/mobile/lib/services/entity.service.dart b/mobile/lib/services/entity.service.dart new file mode 100644 index 0000000000..ddbe77f8c9 --- /dev/null +++ b/mobile/lib/services/entity.service.dart @@ -0,0 +1,53 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; + +class EntityService { + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + EntityService( + this._assetRepository, + this._userRepository, + ); + + Future fillAlbumWithDatabaseEntities(Album album) async { + final ownerId = album.ownerId; + if (ownerId != null) { + // replace owner with user from database + album.owner.value = await _userRepository.get(ownerId); + } + final thumbnailAssetId = + album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId; + if (thumbnailAssetId != null) { + // set thumbnail with asset from database + album.thumbnail.value = + await _assetRepository.getByRemoteId(thumbnailAssetId); + } + if (album.remoteUsers.isNotEmpty) { + // replace all users with users from database + final users = await _userRepository + .getByIds(album.remoteUsers.map((user) => user.id).toList()); + album.sharedUsers.clear(); + album.sharedUsers.addAll(users); + album.shared = true; + } + if (album.remoteAssets.isNotEmpty) { + // replace all assets with assets from database + final assets = await _assetRepository + .getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!)); + album.assets.clear(); + album.assets.addAll(assets); + } + return album; + } +} + +final entityServiceProvider = Provider( + (ref) => EntityService( + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ), +); diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index ffc81a3445..bb19340d2f 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -2,70 +2,92 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; 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/ios_device_asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:photo_manager/photo_manager.dart'; class HashService { - HashService(this._db, this._backgroundService); - final Isar _db; + HashService( + this._assetRepository, + this._backgroundService, + this._albumMediaRepository, + ); + final IAssetRepository _assetRepository; final BackgroundService _backgroundService; + final IAlbumMediaRepository _albumMediaRepository; final _log = Logger('HashService'); /// Returns all assets that were successfully hashed Future> getHashedAssets( - AssetPathEntity album, { + Album album, { int start = 0, int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, Set? excludedAssets, }) async { - final entities = await album.getAssetListRange(start: start, end: end); + final entities = await _albumMediaRepository.getAssets( + album.localId!, + start: start, + end: end, + modifiedFrom: modifiedFrom, + modifiedUntil: modifiedUntil, + ); final filtered = excludedAssets == null ? entities - : entities.where((e) => !excludedAssets.contains(e.id)).toList(); + : entities.where((e) => !excludedAssets.contains(e.localId!)).toList(); return _hashAssets(filtered); } - /// Converts a list of [AssetEntity]s to [Asset]s including only those + /// Processes a list of local [Asset]s, storing their hash and returning only those /// that were successfully hashed. Hashes are looked up in a DB table /// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing /// entries are newly hashed and added to the DB table. - Future> _hashAssets(List assetEntities) async { + Future> _hashAssets(List assets) async { const int batchFileCount = 128; const int batchDataSize = 1024 * 1024 * 1024; // 1GB - final ids = assetEntities - .map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id) + final ids = assets + .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) .toList(); - final List hashes = await _lookupHashes(ids); + final List hashes = + await _assetRepository.getDeviceAssetsById(ids); final List toAdd = []; final List toHash = []; int bytes = 0; - for (int i = 0; i < assetEntities.length; i++) { + for (int i = 0; i < assets.length; i++) { if (hashes[i] != null) { continue; } - final file = await assetEntities[i].originFile; - if (file == null) { - final fileName = await assetEntities[i].titleAsync.catchError((error) { - _log.warning( - "Failed to get title for asset ${assetEntities[i].id}", - ); - return ""; - }); + File? file; + + try { + file = await assets[i].local!.originFile; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", + error, + stackTrace, + ); + } + + if (file == null) { + final fileName = assets[i].fileName; _log.warning( - "Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping", + "Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping", ); continue; } @@ -86,15 +108,9 @@ class HashService { if (toHash.isNotEmpty) { await _processBatch(toHash, toAdd); } - return _mapAllHashedAssets(assetEntities, hashes); + return _getHashedAssets(assets, hashes); } - /// Lookup hashes of assets by their local ID - Future> _lookupHashes(List ids) => - Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); - /// Processes a batch of files and saves any successfully hashed /// values to the DB table. Future _processBatch( @@ -114,11 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _db.writeTxn( - () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(validHashes.cast()) - : _db.iOSDeviceAssets.putAll(validHashes.cast()), - ); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } @@ -133,15 +147,16 @@ class HashService { return hashes; } - /// Converts [AssetEntity]s that were successfully hashed to [Asset]s - List _mapAllHashedAssets( - List assets, + /// Returns all successfully hashed [Asset]s with their hash value set + List _getHashedAssets( + List assets, List hashes, ) { final List result = []; for (int i = 0; i < assets.length; i++) { if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) { - result.add(Asset.local(assets[i], hashes[i]!.hash)); + assets[i].byteHash = hashes[i]!.hash; + result.add(assets[i]); } } return result; @@ -150,7 +165,8 @@ class HashService { final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ref.watch(backgroundServiceProvider), + ref.watch(albumMediaRepositoryProvider), ), ); diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index e61573af37..0000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:photo_manager/photo_manager.dart'; -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = - Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider))); - -class ImageViewerService { - final ApiService _apiService; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService); - - Future downloadAssetToDevice(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - AssetEntity? entity; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - entity = await PhotoManager.editor.darwin.saveLivePhoto( - imageFile: imageFile, - videoFile: videoFile, - title: asset.fileName, - ); - - if (entity == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - - entity = await PhotoManager.editor.saveImage( - imageResponse.bodyBytes, - title: asset.fileName, - ); - } - - return entity != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final AssetEntity? entity; - - if (asset.isImage) { - entity = await PhotoManager.editor.saveImage( - res.bodyBytes, - title: asset.fileName, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - entity = await PhotoManager.editor - .saveVideo(videoFile, title: asset.fileName); - } - return entity != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/services/local_notification.service.dart b/mobile/lib/services/local_notification.service.dart index 2463777331..b47ee280b8 100644 --- a/mobile/lib/services/local_notification.service.dart +++ b/mobile/lib/services/local_notification.service.dart @@ -29,7 +29,8 @@ class LocalNotificationService { static const cancelUploadActionID = 'cancel_upload'; Future setup() async { - const androidSetting = AndroidInitializationSettings('notification_icon'); + const androidSetting = + AndroidInitializationSettings('@drawable/notification_icon'); const iosSetting = DarwinInitializationSettings(); const initSettings = diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index ea07f7c019..b95899df67 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,18 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; 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/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ); }); @@ -20,9 +19,9 @@ class MemoryService { final log = Logger("MemoryService"); final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; - MemoryService(this._apiService, this._db); + MemoryService(this._apiService, this._assetRepository); Future?> getMemoryLane() async { try { @@ -39,7 +38,7 @@ class MemoryService { List memories = []; for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); 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 807c88db8d..30e6448d7f 100644 --- a/mobile/lib/services/oauth.service.dart +++ b/mobile/lib/services/oauth.service.dart @@ -3,7 +3,7 @@ import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:flutter_web_auth/flutter_web_auth.dart'; -// Redirect URL = app.immich:// +// Redirect URL = app.immich:///oauth-callback class OAuthService { final ApiService _apiService; @@ -16,28 +16,40 @@ class OAuthService { ) async { // Resolve API server endpoint from user provided serverUrl await _apiService.resolveAndSetEndpoint(serverUrl); + final redirectUri = '$callbackUrlScheme:///oauth-callback'; + log.info( + "Starting OAuth flow with redirect URI: $redirectUri", + ); final dto = await _apiService.oAuthApi.startOAuth( - OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'), + OAuthConfigDto(redirectUri: redirectUri), ); - return dto?.url; + + final authUrl = dto?.url; + log.info('Received Authorization URL: $authUrl'); + + return authUrl; } Future oAuthLogin(String oauthUrl) async { - try { - var result = await FlutterWebAuth.authenticate( - url: oauthUrl, - callbackUrlScheme: callbackUrlScheme, - ); + String result = await FlutterWebAuth.authenticate( + url: oauthUrl, + callbackUrlScheme: callbackUrlScheme, + ); - return await _apiService.oAuthApi.finishOAuth( - OAuthCallbackDto( - url: result, - ), + log.info('Received OAuth callback: $result'); + + if (result.startsWith('app.immich:/oauth-callback')) { + result = result.replaceAll( + 'app.immich:/oauth-callback', + 'app.immich:///oauth-callback', ); - } catch (e, stack) { - log.severe("OAuth login failed", e, stack); - return null; } + + return await _apiService.oAuthApi.finishOAuth( + OAuthCallbackDto( + url: result, + ), + ); } } diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 8cd2fe424f..67d7f4e1d1 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,43 +1,33 @@ import 'package:hooks_riverpod/hooks_riverpod.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/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final partnerServiceProvider = Provider( (ref) => PartnerService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userRepositoryProvider), ), ); class PartnerService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); - PartnerService(this._apiService, this._db); - - Future?> getPartners(PartnerDirection direction) async { - try { - final userDtos = await _apiService.partnersApi.getPartners(direction); - if (userDtos != null) { - return userDtos.map((u) => User.fromPartnerDto(u)).toList(); - } - } catch (e) { - _log.warning("Failed to get partners for direction $direction", e); - } - return null; - } + PartnerService( + this._partnerApiRepository, + this._userRepository, + ); Future removePartner(User partner) async { try { - await _apiService.partnersApi.removePartner(partner.id); + await _partnerApiRepository.delete(partner.id); partner.isPartnerSharedBy = false; - await _db.writeTxn(() => _db.users.put(partner)); + await _userRepository.update(partner); } catch (e) { _log.warning("Failed to remove partner ${partner.id}", e); return false; @@ -47,12 +37,10 @@ class PartnerService { Future addPartner(User partner) async { try { - final dto = await _apiService.partnersApi.createPartner(partner.id); - if (dto != null) { - partner.isPartnerSharedBy = true; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + await _partnerApiRepository.create(partner.id); + partner.isPartnerSharedBy = true; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to add partner ${partner.id}", e); } @@ -61,13 +49,13 @@ class PartnerService { Future updatePartner(User partner, {required bool inTimeline}) async { try { - final dto = await _apiService.partnersApi - .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline)); - if (dto != null) { - partner.inTimeline = dto.inTimeline ?? partner.inTimeline; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + final dto = await _partnerApiRepository.update( + partner.id, + inTimeline: inTimeline, + ); + partner.inTimeline = dto.inTimeline; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to update partner ${partner.id}", e); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index f35ae1a225..5b325acdc5 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,54 +1,57 @@ import 'package:immich_mobile/entities/asset.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/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => - PersonService(ref.read(apiServiceProvider), ref.read(dbProvider)); +PersonService personService(PersonServiceRef ref) => PersonService( + ref.watch(personApiRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ref.read(assetRepositoryProvider), + ); class PersonService { final Logger _log = Logger("PersonService"); - final ApiService _apiService; - final Isar _db; + final IPersonApiRepository _personApiRepository; + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; - PersonService(this._apiService, this._db); + PersonService( + this._personApiRepository, + this._assetApiRepository, + this._assetRepository, + ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { - final peopleResponseDto = await _apiService.peopleApi.getAllPeople(); - return peopleResponseDto?.people ?? []; + return await _personApiRepository.getAll(); } catch (error, stack) { _log.severe("Error while fetching curated people", error, stack); return []; } } - Future?> getPersonAssets(String id) async { + Future> getPersonAssets(String id) async { try { - final assets = await _apiService.peopleApi.getPersonAssets(id); - if (assets == null) return null; - return await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + final assets = await _assetApiRepository.search(personIds: [id]); + return await _assetRepository + .getAllByRemoteId(assets.map((a) => a.remoteId!)); } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - return null; + return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { - return await _apiService.peopleApi.updatePerson( - id, - PersonUpdateDto( - name: name, - ), - ); + return await _personApiRepository.update(id, name: name); } catch (error, stack) { _log.severe("Error while updating person name", error, stack); } diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 01a5ed8f30..9a24069fbf 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'54e6df4b8eea744f6de009f8315c9fe6230f6798'; +String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; /// See also [personService]. @ProviderFor(personService) diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index cf3905e5ca..3dd0106c09 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,27 +1,29 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); class SearchService { final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._db); + SearchService(this._apiService, this._assetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -44,7 +46,7 @@ class SearchService { } } - Future?> search(SearchFilter filter, int page) async { + Future search(SearchFilter filter, int page) async { try { SearchResponseDto? response; AssetTypeEnum? type; @@ -103,8 +105,12 @@ class SearchService { return null; } - return _db.assets - .getAllByRemoteId(response.assets.items.map((e) => e.id)); + return SearchResult( + assets: await _assetRepository.getAllByRemoteId( + response.assets.items.map((e) => e.id), + ), + nextPage: response.assets.nextPage?.toInt(), + ); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); } diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart new file mode 100644 index 0000000000..1ca56ff279 --- /dev/null +++ b/mobile/lib/services/stack.service.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +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/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; + +class StackService { + StackService(this._api, this._assetRepository); + + final ApiService _api; + final IAssetRepository _assetRepository; + + Future getStack(String stackId) async { + try { + return _api.stacksApi.getStack(stackId); + } catch (error) { + debugPrint("Error while fetching stack: $error"); + } + return null; + } + + Future createStack(List assetIds) async { + try { + return _api.stacksApi.createStack( + StackCreateDto(assetIds: assetIds), + ); + } catch (error) { + debugPrint("Error while creating stack: $error"); + } + return null; + } + + Future updateStack( + String stackId, + String primaryAssetId, + ) async { + try { + return await _api.stacksApi.updateStack( + stackId, + StackUpdateDto(primaryAssetId: primaryAssetId), + ); + } catch (error) { + debugPrint("Error while updating stack children: $error"); + } + return null; + } + + Future deleteStack(String stackId, List assets) async { + try { + await _api.stacksApi.deleteStack(stackId); + + // Update local database to trigger rerendering + final List removeAssets = []; + for (final asset in assets) { + asset.stackId = null; + asset.stackPrimaryAssetId = null; + asset.stackCount = 0; + + removeAssets.add(asset); + } + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); + } catch (error) { + debugPrint("Error while deleting stack: $error"); + } + } +} + +final stackServiceProvider = Provider( + (ref) => StackService( + ref.watch(apiServiceProvider), + ref.watch(assetRepositoryProvider), + ), +); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 8ec56e925f..f1a6e9b0d7 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,31 +5,67 @@ 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/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/providers/db.provider.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/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:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; final syncServiceProvider = Provider( - (ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)), + (ref) => SyncService( + ref.watch(hashServiceProvider), + ref.watch(entityServiceProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ), ); class SyncService { - final Isar _db; final HashService _hashService; + final EntityService _entityService; + final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); - SyncService(this._db, this._hashService); + SyncService( + this._hashService, + this._entityService, + this._albumMediaRepository, + this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, + ); // public methods: @@ -59,16 +95,14 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future syncRemoteAlbumsToDb( - List remote, { - required bool isShared, - required FutureOr Function(AlbumResponseDto) loadDetails, - }) => - _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); + List remote, + ) => + _lock.run(() => _syncRemoteAlbumsToDb(remote)); /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes Future syncLocalAlbumAssetsToDb( - List onDevice, [ + List onDevice, [ Set? excludedAssets, ]) => _lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets)); @@ -102,8 +136,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); - assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); final List toDelete = []; final List toUpsert = []; final changes = diffSortedListsSync( @@ -124,9 +157,9 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); }); } return changes; @@ -135,15 +168,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -158,9 +191,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -181,7 +214,7 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; @@ -189,23 +222,21 @@ class SyncService { /// Deletes remote-only assets, updates merged assets to be local-only Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; } + await _assetRepository.updateAll(merged); }); } @@ -220,12 +251,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -242,11 +268,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -261,9 +286,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -272,55 +297,44 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database /// returns `true` if there were any changes Future _syncRemoteAlbumsToDb( - List remote, - bool isShared, - FutureOr Function(AlbumResponseDto) loadDetails, + List remoteAlbums, ) async { - remote.sortBy((e) => e.id); + remoteAlbums.sortBy((e) => e.remoteId!); - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); - assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + sortBy: AlbumSort.remoteId, + ); final List toDelete = []; final List existing = []; final bool changes = await diffSortedLists( - remote, + remoteAlbums, dbAlbums, - compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!), - both: (AlbumResponseDto a, Album b) => - _syncRemoteAlbum(a, b, toDelete, existing, loadDetails), - onlyFirst: (AlbumResponseDto a) => - _addAlbumFromServer(a, existing, loadDetails), - onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete), + compare: (remoteAlbum, dbAlbum) => + remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!), + both: (remoteAlbum, dbAlbum) => + _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing), + onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing), + onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete), ); - if (isShared && toDelete.isNotEmpty) { + if (toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -332,26 +346,25 @@ class SyncService { /// syncing changes from local back to server) /// accumulates Future _syncRemoteAlbum( - AlbumResponseDto dto, + Album dto, Album album, List deleteCandidates, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (!_hasAlbumResponseDtoChanged(dto, album)) { + if (!_hasRemoteAlbumChanged(dto, album)) { return false; } // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp, // i.e. it will always be null. Save it here. final originalDto = dto; - dto = await loadDetails(dto); - if (dto.assetCount != dto.assets.length) { - return false; - } - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + dto = await _albumApiRepository.get(dto.remoteId!); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); - final List assetsOnRemote = dto.getAssets(); + final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); final (toAdd, toUpdate, toUnlink) = _diffAssets( assetsOnRemote, @@ -362,15 +375,16 @@ class SyncService { // update shared users final List sharedUsers = album.sharedUsers.toList(growable: false); sharedUsers.sort((a, b) => a.id.compareTo(b.id)); - dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); + final List users = dto.remoteUsers.toList() + ..sort((a, b) => a.id.compareTo(b.id)); final List userIdsToAdd = []; final List usersToUnlink = []; diffSortedListsSync( - dto.albumUsers, + users, sharedUsers, - compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), + compare: (User a, User b) => a.id.compareTo(b.id), both: (a, b) => false, - onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), + onlyFirst: (User a) => userIdsToAdd.add(a.id), onlySecond: (User a) => usersToUnlink.add(a), ); @@ -378,43 +392,44 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); - album.name = dto.albumName; + album.name = dto.name; album.shared = dto.shared; album.createdAt = dto.createdAt; - album.modifiedAt = dto.updatedAt; + album.modifiedAt = dto.modifiedAt; album.startDate = dto.startDate; album.endDate = dto.endDate; album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; - album.activityEnabled = dto.isActivityEnabled; - if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.albumThumbnailAssetId) - .findFirst(); + album.activityEnabled = dto.activityEnabled; + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); }); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -428,27 +443,26 @@ class SyncService { /// (shared) assets to the database beforehand /// accumulates assets already existing in the database Future _addAlbumFromServer( - AlbumResponseDto dto, + Album album, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (dto.assetCount != dto.assets.length) { - dto = await loadDetails(dto); + if (album.remoteAssetCount != album.remoteAssets.length) { + album = await _albumApiRepository.get(album.remoteId!); } - if (dto.assetCount == dto.assets.length) { + if (album.remoteAssetCount == album.remoteAssets.length) { // in case an album contains assets not yet present in local DB: // put missing album assets into local DB final (existingInDb, updated) = - await _linkWithExistingFromDb(dto.getAssets()); + await _linkWithExistingFromDb(album.remoteAssets.toList()); existing.addAll(existingInDb); await upsertAssetsWithExif(updated); - final Album a = await Album.remote(dto); - await _db.writeTxn(() => _db.albums.store(a)); + await _entityService.fillAlbumWithDatabaseEntities(album); + await _albumRepository.create(album); } else { _log.warning( - "Failed to add album from server: assetCount ${dto.assetCount} != " - "asset array length ${dto.assets.length} for album ${dto.albumName}"); + "Failed to add album from server: assetCount ${album.remoteAssetCount} != " + "asset array length ${album.remoteAssets.length} for album ${album.name}"); } } @@ -462,27 +476,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -492,28 +497,26 @@ class SyncService { /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes Future _syncLocalAlbumAssetsToDb( - List onDevice, [ + List onDevice, [ Set? excludedAssets, ]) async { - onDevice.sort((a, b) => a.id.compareTo(b.id)); + onDevice.sort((a, b) => a.localId!.compareTo(b.localId!)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; - assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); final bool anyChanges = await diffSortedLists( onDevice, inDb, - compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!), - both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice( - ape, - album, + compare: (Album a, Album b) => a.localId!.compareTo(b.localId!), + both: (Album a, Album b) => _syncAlbumInDbAndOnDevice( + a, + b, deleteCandidates, existing, excludedAssets, ), - onlyFirst: (AssetPathEntity ape) => - _addAlbumFromDevice(ape, existing, excludedAssets), + onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets), onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), ); _log.fine( @@ -525,10 +528,9 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", @@ -541,58 +543,64 @@ class SyncService { /// returns `true` if there were any changes /// Accumulates asset candidates to delete and those already existing in DB Future _syncAlbumInDbAndOnDevice( - AssetPathEntity ape, - Album album, + Album deviceAlbum, + Album dbAlbum, List deleteCandidates, List existing, [ Set? excludedAssets, bool forceRefresh = false, ]) async { - if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) { - _log.fine("Local album ${ape.name} has not changed. Skipping sync."); + if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) { + _log.fine( + "Local album ${deviceAlbum.name} has not changed. Skipping sync.", + ); return false; } if (!forceRefresh && excludedAssets == null && - await _syncDeviceAlbumFast(ape, album)) { + await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await album.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); - final int assetCountOnDevice = await ape.assetCountAsync; - final List onDevice = - await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); + final int assetCountOnDevice = + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); + final List onDevice = await _hashService.getHashedAssets( + deviceAlbum, + excludedAssets: excludedAssets, + ); _removeDuplicates(onDevice); // _removeDuplicates sorts `onDevice` by checksum final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb); if (toAdd.isEmpty && toUpdate.isEmpty && toDelete.isEmpty && - album.name == ape.name && - ape.lastModified != null && - album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) { + dbAlbum.name == deviceAlbum.name && + dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) { // changes only affeted excluded albums _log.fine( - "Only excluded assets in local album ${ape.name} changed. Stopping sync.", + "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } _log.fine( - "Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", + "Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", ); final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); _log.fine( @@ -600,28 +608,29 @@ class SyncService { ); deleteCandidates.addAll(toDelete); existing.addAll(existingInDb); - album.name = ape.name; - album.modifiedAt = ape.lastModified ?? DateTime.now(); - if (album.thumbnail.value != null && - toDelete.contains(album.thumbnail.value)) { - album.thumbnail.value = null; + dbAlbum.name = deviceAlbum.name; + dbAlbum.modifiedAt = deviceAlbum.modifiedAt; + if (dbAlbum.thumbnail.value != null && + toDelete.contains(dbAlbum.thumbnail.value)) { + dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await album.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(album); - album.thumbnail.value ??= await album.assets.filter().findFirst(); - await album.thumbnail.save(); - await _db.eTags.put( - ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), - ); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, + ), + ]); }); - _log.info("Synced changes of local album ${ape.name} to DB"); - } on IsarError catch (e) { - _log.severe("Failed to update synced album ${ape.name} in DB", e); + _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); + } catch (e) { + _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } return true; @@ -629,45 +638,47 @@ class SyncService { /// fast path for common case: only new assets were added to device album /// returns `true` if successfull, else `false` - Future _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async { - if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) { + Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { + if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { return false; } - final int totalOnDevice = await ape.assetCountAsync; + final int totalOnDevice = + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0; - final AssetPathEntity? modified = totalOnDevice > lastKnownTotal - ? await ape.fetchPathProperties( - filterOptionGroup: FilterOptionGroup( - updateTimeCond: DateTimeCond( - min: album.modifiedAt.add(const Duration(seconds: 1)), - max: ape.lastModified ?? DateTime.now(), - ), - ), - ) - : null; - if (modified == null) { + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? + 0; + if (totalOnDevice <= lastKnownTotal) { return false; } - final List newAssets = await _hashService.getHashedAssets(modified); + final List newAssets = await _hashService.getHashedAssets( + deviceAlbum, + modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)), + modifiedUntil: deviceAlbum.modifiedAt, + ); if (totalOnDevice != lastKnownTotal + newAssets.length) { return false; } - album.modifiedAt = ape.lastModified ?? DateTime.now(); + dbAlbum.modifiedAt = deviceAlbum.modifiedAt; _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await album.assets.update(link: existingInDb + updated); - await _db.albums.put(album); - await _db.eTags - .put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice)); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], + ); }); - _log.info("Fast synced local album ${ape.name} to DB"); - } on IsarError catch (e) { - _log.severe("Failed to fast sync local album ${ape.name} to DB", e); + _log.info("Fast synced local album ${deviceAlbum.name} to DB"); + } catch (e) { + _log.severe( + "Failed to fast sync local album ${deviceAlbum.name} to DB", + e, + ); return false; } @@ -677,14 +688,15 @@ class SyncService { /// Adds a new album from the device to the database and Accumulates all /// assets already existing in the database to the list of `existing` assets Future _addAlbumFromDevice( - AssetPathEntity ape, + Album album, List existing, [ Set? excludedAssets, ]) async { - _log.info("Syncing a new local album to DB: ${ape.name}"); - final Album a = Album.local(ape); - final assets = - await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); + _log.info("Syncing a new local album to DB: ${album.name}"); + final assets = await _hashService.getHashedAssets( + album, + excludedAssets: excludedAssets, + ); _removeDuplicates(assets); final (existingInDb, updated) = await _linkWithExistingFromDb(assets); _log.info( @@ -692,15 +704,15 @@ class SyncService { ); await upsertAssetsWithExif(updated); existing.addAll(existingInDb); - a.assets.addAll(existingInDb); - a.assets.addAll(updated); + album.assets.addAll(existingInDb); + album.assets.addAll(updated); final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; - a.thumbnail.value = thumb; + album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(a)); - _log.info("Added a new local album to DB: ${ape.name}"); - } on IsarError catch (e) { - _log.severe("Failed to add new local album ${ape.name} to DB", e); + await _albumRepository.create(album); + _log.info("Added a new local album to DB: ${album.name}"); + } catch (e) { + _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -710,7 +722,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -724,7 +736,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -736,24 +748,22 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); for (final Asset added in assets) { added.exifInfo?.id = added.id; } - await _db.exifInfos.putAll(exifInfos); + await _exifInfoRepository.updateAll(exifInfos); }); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -761,7 +771,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -787,8 +797,7 @@ class SyncService { assets.sort(Asset.compareByOwnerChecksumCreatedModified); assets.uniqueConsecutive( compare: Asset.compareByOwnerChecksum, - onDuplicate: (a, b) => - _log.info("Ignoring duplicate assets on device:\n$a\n$b"), + onDuplicate: (a, b) => {}, ); final int duplicates = before - assets.length; if (duplicates > 0) { @@ -798,23 +807,26 @@ class SyncService { } /// returns `true` if the albums differ on the surface - Future _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async { - return a.name != b.name || - a.lastModified == null || - !a.lastModified!.isAtSameMomentAs(b.modifiedAt) || - await a.assetCountAsync != - (await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount; + Future _hasAlbumChangeOnDevice( + Album deviceAlbum, + Album dbAlbum, + ) async { + return deviceAlbum.name != dbAlbum.name || + !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); }); return true; } catch (e) { @@ -900,17 +912,17 @@ class SyncService { } /// returns `true` if the albums differ on the surface -bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { - return dto.assetCount != a.assetCount || - dto.albumName != a.name || - dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || - dto.shared != a.shared || - dto.albumUsers.length != a.sharedUsers.length || - !dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || - !isAtSameMomentAs(dto.startDate, a.startDate) || - !isAtSameMomentAs(dto.endDate, a.endDate) || +bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) { + return remoteAlbum.remoteAssetCount != dbAlbum.assetCount || + remoteAlbum.name != dbAlbum.name || + remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId || + remoteAlbum.shared != dbAlbum.shared || + remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length || + !remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || + !isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) || + !isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) || !isAtSameMomentAs( - dto.lastModifiedAssetTimestamp, - a.lastModifiedAssetTimestamp, + remoteAlbum.lastModifiedAssetTimestamp, + dbAlbum.lastModifiedAssetTimestamp, ); } diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 9631141c41..4c2b3cbbd0 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,68 +1,48 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/services/partner.service.dart'; -import 'package:immich_mobile/entities/store.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/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final userServiceProvider = Provider( (ref) => UserService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), + ref.watch(userRepositoryProvider), ref.watch(syncServiceProvider), - ref.watch(partnerServiceProvider), ), ); class UserService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; + final IUserRepository _userRepository; final SyncService _syncService; - final PartnerService _partnerService; final Logger _log = Logger("UserService"); UserService( - this._apiService, - this._db, + this._partnerApiRepository, + this._userApiRepository, + this._userRepository, this._syncService, - this._partnerService, ); - Future?> _getAllUsers() async { - try { - final dto = await _apiService.usersApi.searchUsers(); - return dto?.map(User.fromSimpleUserDto).toList(); - } catch (e) { - _log.warning("Failed get all users", e); - return null; - } - } + Future> getUsers({bool self = false}) => + _userRepository.getAll(self: self); - Future> getUsersInDb({bool self = false}) async { - if (self) { - return _db.users.where().findAll(); - } - final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); - } - - Future uploadProfileImage(XFile image) async { + Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { - return await _apiService.usersApi.createProfileImage( - MultipartFile.fromBytes( - 'file', - await image.readAsBytes(), - filename: image.name, - ), + return await _userApiRepository.createProfileImage( + name: image.name, + data: await image.readAsBytes(), ); } catch (e) { _log.warning("Failed to upload profile image", e); @@ -71,13 +51,19 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(); - final List? sharedBy = - await _partnerService.getPartners(PartnerDirection.by); - final List? sharedWith = - await _partnerService.getPartners(PartnerDirection.with_); + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); - if (users == null || sharedBy == null || sharedWith == null) { + if (users == null) { _log.warning("Failed to refresh users"); return null; } diff --git a/mobile/lib/utils/diff.dart b/mobile/lib/utils/diff.dart index 18e3843819..a36902d8c7 100644 --- a/mobile/lib/utils/diff.dart +++ b/mobile/lib/utils/diff.dart @@ -1,16 +1,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; + /// Efficiently compares two sorted lists in O(n), calling the given callback /// for each item. /// Return `true` if there are any differences found, else `false` -Future diffSortedLists( - List la, - List lb, { - required int Function(A a, B b) compare, - required FutureOr Function(A a, B b) both, - required FutureOr Function(A a) onlyFirst, - required FutureOr Function(B b) onlySecond, +Future diffSortedLists( + List la, + List lb, { + required int Function(T a, T b) compare, + required FutureOr Function(T a, T b) both, + required FutureOr Function(T a) onlyFirst, + required FutureOr Function(T b) onlySecond, }) async { + assert(la.isSorted(compare), "first argument must be sorted"); + assert(lb.isSorted(compare), "second argument must be sorted"); bool diff = false; int i = 0, j = 0; for (; i < la.length && j < lb.length;) { @@ -38,14 +42,16 @@ Future diffSortedLists( /// Efficiently compares two sorted lists in O(n), calling the given callback /// for each item. /// Return `true` if there are any differences found, else `false` -bool diffSortedListsSync( - List la, - List lb, { - required int Function(A a, B b) compare, - required bool Function(A a, B b) both, - required void Function(A a) onlyFirst, - required void Function(B b) onlySecond, +bool diffSortedListsSync( + List la, + List lb, { + required int Function(T a, T b) compare, + required bool Function(T a, T b) both, + required void Function(T a) onlyFirst, + required void Function(T b) onlySecond, }) { + assert(la.isSorted(compare), "first argument must be sorted"); + assert(lb.isSorted(compare), "second argument must be sorted"); bool diff = false; int i = 0, j = 0; for (; i < la.length && j < lb.length;) { diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000..c701f353a2 --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart index b03d9ccdb0..04bc978754 100644 --- a/mobile/lib/utils/hooks/crop_controller_hook.dart +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -6,7 +6,7 @@ import 'dart:ui'; // Import the dart:ui library for Rect CropController useCropController() { return useMemoized( () => CropController( - defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9), + defaultCrop: const Rect.fromLTRB(0, 0, 1, 1), ), ); } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index b6c7f2ba8b..9fc7b13eed 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,7 +1,7 @@ +import 'package:immich_mobile/constants/constants.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:isar/isar.dart'; import 'package:openapi/api.dart'; String getThumbnailUrl( @@ -55,17 +55,13 @@ String getAlbumThumbNailCacheKey( ); } -String getImageUrl(final Asset asset) { - return getImageUrlFromId(asset.remoteId!); -} - -String getImageUrlFromId(final String id) { - return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=preview'; +String getOriginalUrlForRemoteId(final String id) { + return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original'; } String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; return '${isFromDto ? asset.remoteId : asset.id}_fullStage'; } diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 32a26439d5..c0cf60514f 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -1,10 +1,22 @@ +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -final immichThemeProvider = StateProvider((ref) { +class ImmichTheme { + ColorScheme light; + ColorScheme dark; + + ImmichTheme({required this.light, required this.dark}); +} + +ImmichTheme? _immichDynamicTheme; +bool get isDynamicThemeAvailable => _immichDynamicTheme != null; + +final immichThemeModeProvider = StateProvider((ref) { var themeMode = ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.themeMode); @@ -20,266 +32,269 @@ final immichThemeProvider = StateProvider((ref) { } }); -final ThemeData base = ThemeData( - chipTheme: const ChipThemeData( - side: BorderSide.none, - ), - sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, - ), -); +final immichThemePresetProvider = StateProvider((ref) { + var appSettingsProvider = ref.watch(appSettingsServiceProvider); + var primaryColorName = + appSettingsProvider.getSetting(AppSettingsEnum.primaryColor); -final ThemeData immichLightTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.indigo, - ), - primarySwatch: Colors.indigo, - primaryColor: Colors.indigo, - hintColor: Colors.indigo, - focusColor: Colors.indigo, - splashColor: Colors.indigo.withOpacity(0.15), - fontFamily: 'Overpass', - scaffoldBackgroundColor: immichBackgroundColor, - snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle( - fontFamily: 'Overpass', - color: Colors.indigo, - fontWeight: FontWeight.bold, - ), - backgroundColor: Colors.white, - ), - appBarTheme: const AppBarTheme( - titleTextStyle: TextStyle( - fontFamily: 'Overpass', - color: Colors.indigo, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - backgroundColor: immichBackgroundColor, - foregroundColor: Colors.indigo, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - backgroundColor: immichBackgroundColor, - selectedItemColor: Colors.indigo, - ), - cardTheme: const CardTheme( - surfaceTintColor: Colors.transparent, - ), - drawerTheme: const DrawerThemeData( - backgroundColor: immichBackgroundColor, - ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Colors.indigo, - ), - displayMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - displaySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.indigo, - ), - titleSmall: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - titleMedium: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - titleLarge: TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - ), - ), - chipTheme: base.chipTheme, - sliderTheme: base.sliderTheme, - popupMenuTheme: const PopupMenuThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - surfaceTintColor: Colors.transparent, - color: Colors.white, - ), - navigationBarTheme: NavigationBarThemeData( - indicatorColor: Colors.indigo.withOpacity(0.15), - iconTheme: WidgetStatePropertyAll( - IconThemeData(color: Colors.grey[700]), - ), - backgroundColor: immichBackgroundColor, - surfaceTintColor: Colors.transparent, - labelTextStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[800], - ), - ), - ), - dialogTheme: const DialogTheme( - surfaceTintColor: Colors.transparent, - ), - inputDecorationTheme: const InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.indigo, - ), - ), - labelStyle: TextStyle( - color: Colors.indigo, - ), - hintStyle: TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, - ), - ), - textSelectionTheme: const TextSelectionThemeData( - cursorColor: Colors.indigo, - ), -); + debugPrint("Current theme preset $primaryColorName"); -final ThemeData immichDarkTheme = ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - primarySwatch: Colors.indigo, - primaryColor: immichDarkThemePrimaryColor, - colorScheme: ColorScheme.fromSeed( - seedColor: immichDarkThemePrimaryColor, - brightness: Brightness.dark, - ), - scaffoldBackgroundColor: immichDarkBackgroundColor, - hintColor: Colors.grey[600], - fontFamily: 'Overpass', - snackBarTheme: SnackBarThemeData( - contentTextStyle: const TextStyle( - fontFamily: 'Overpass', - color: immichDarkThemePrimaryColor, - fontWeight: FontWeight.bold, + try { + return ImmichColorPreset.values + .firstWhere((e) => e.name == primaryColorName); + } catch (e) { + debugPrint( + "Theme preset $primaryColorName not found. Applying default preset.", + ); + appSettingsProvider.setSetting( + AppSettingsEnum.primaryColor, + defaultColorPresetName, + ); + return defaultColorPreset; + } +}); + +final dynamicThemeSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.dynamicTheme); +}); + +final colorfulInterfaceSettingProvider = StateProvider((ref) { + return ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.colorfulInterface); +}); + +// Provider for current selected theme +final immichThemeProvider = StateProvider((ref) { + var primaryColor = ref.read(immichThemePresetProvider); + var useSystemColor = ref.watch(dynamicThemeSettingProvider); + var useColorfulInterface = ref.watch(colorfulInterfaceSettingProvider); + + var currentTheme = (useSystemColor && _immichDynamicTheme != null) + ? _immichDynamicTheme! + : primaryColor.getTheme(); + + return useColorfulInterface + ? currentTheme + : _decolorizeSurfaces(theme: currentTheme); +}); + +// Method to fetch dynamic system colors +Future fetchSystemPalette() async { + try { + final corePalette = await DynamicColorPlugin.getCorePalette(); + if (corePalette != null) { + final primaryColor = corePalette.toColorScheme().primary; + debugPrint('dynamic_color: Core palette detected.'); + + // Some palettes do not generate surface container colors accurately, + // so we regenerate all colors using the primary color + _immichDynamicTheme = ImmichTheme( + light: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + dark: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + ); + } + } catch (e) { + debugPrint('dynamic_color: Failed to obtain core palette.'); + } +} + +// This method replaces all surface shades in ImmichTheme to a static ones +// as we are creating the colorscheme through seedColor the default surfaces are +// tinted with primary color +ImmichTheme _decolorizeSurfaces({ + required ImmichTheme theme, +}) { + return ImmichTheme( + light: theme.light.copyWith( + surface: const Color(0xFFf9f9f9), + onSurface: const Color(0xFF1b1b1b), + surfaceContainerLowest: const Color(0xFFffffff), + surfaceContainerLow: const Color(0xFFf3f3f3), + surfaceContainer: const Color(0xFFeeeeee), + surfaceContainerHigh: const Color(0xFFe8e8e8), + surfaceContainerHighest: const Color(0xFFe2e2e2), + surfaceDim: const Color(0xFFdadada), + surfaceBright: const Color(0xFFf9f9f9), + onSurfaceVariant: const Color(0xFF4c4546), + inverseSurface: const Color(0xFF303030), + onInverseSurface: const Color(0xFFf1f1f1), ), - backgroundColor: Colors.grey[900], - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: immichDarkThemePrimaryColor, + dark: theme.dark.copyWith( + surface: const Color(0xFF131313), + onSurface: const Color(0xFFE2E2E2), + surfaceContainerLowest: const Color(0xFF0E0E0E), + surfaceContainerLow: const Color(0xFF1B1B1B), + surfaceContainer: const Color(0xFF1F1F1F), + surfaceContainerHigh: const Color(0xFF242424), + surfaceContainerHighest: const Color(0xFF2E2E2E), + surfaceDim: const Color(0xFF131313), + surfaceBright: const Color(0xFF353535), + onSurfaceVariant: const Color(0xFFCfC4C5), + inverseSurface: const Color(0xFFE2E2E2), + onInverseSurface: const Color(0xFF303030), ), - ), - appBarTheme: const AppBarTheme( - titleTextStyle: TextStyle( - fontFamily: 'Overpass', - color: immichDarkThemePrimaryColor, - fontWeight: FontWeight.bold, - fontSize: 18, + ); +} + +ThemeData getThemeData({required ColorScheme colorScheme}) { + var isDark = colorScheme.brightness == Brightness.dark; + var primaryColor = colorScheme.primary; + + return ThemeData( + useMaterial3: true, + brightness: isDark ? Brightness.dark : Brightness.light, + colorScheme: colorScheme, + primaryColor: primaryColor, + hintColor: colorScheme.onSurfaceSecondary, + focusColor: primaryColor, + scaffoldBackgroundColor: colorScheme.surface, + splashColor: primaryColor.withOpacity(0.1), + highlightColor: primaryColor.withOpacity(0.1), + dialogBackgroundColor: colorScheme.surfaceContainer, + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: colorScheme.surfaceContainer, ), - backgroundColor: Color.fromARGB(255, 32, 33, 35), - foregroundColor: immichDarkThemePrimaryColor, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - type: BottomNavigationBarType.fixed, - backgroundColor: Color.fromARGB(255, 35, 36, 37), - selectedItemColor: immichDarkThemePrimaryColor, - ), - drawerTheme: DrawerThemeData( - backgroundColor: immichDarkBackgroundColor, - scrimColor: Colors.white.withOpacity(0.1), - ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Color.fromARGB(255, 255, 255, 255), + fontFamily: 'Overpass', + snackBarTheme: SnackBarThemeData( + contentTextStyle: TextStyle( + fontFamily: 'Overpass', + color: primaryColor, + fontWeight: FontWeight.bold, + ), + backgroundColor: colorScheme.surfaceContainerHighest, ), - displayMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Color.fromARGB(255, 255, 255, 255), + appBarTheme: AppBarTheme( + titleTextStyle: TextStyle( + color: primaryColor, + fontFamily: 'Overpass', + fontWeight: FontWeight.bold, + fontSize: 18, + ), + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + foregroundColor: primaryColor, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, ), - displaySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: immichDarkThemePrimaryColor, - ), - titleSmall: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - titleMedium: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - ), - titleLarge: TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - ), - ), - cardColor: Colors.grey[900], - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.black87, - backgroundColor: immichDarkThemePrimaryColor, - ), - ), - chipTheme: base.chipTheme, - sliderTheme: base.sliderTheme, - popupMenuTheme: const PopupMenuThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - surfaceTintColor: Colors.transparent, - ), - navigationBarTheme: NavigationBarThemeData( - indicatorColor: immichDarkThemePrimaryColor.withOpacity(0.4), - iconTheme: WidgetStatePropertyAll( - IconThemeData(color: Colors.grey[500]), - ), - backgroundColor: Colors.grey[900], - surfaceTintColor: Colors.transparent, - labelTextStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[300], + textTheme: TextTheme( + displayLarge: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + ), + displayMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + displaySmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + titleSmall: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + titleMedium: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.bold, + ), + titleLarge: const TextStyle( + fontSize: 26.0, + fontWeight: FontWeight.bold, ), ), - ), - dialogTheme: const DialogTheme( - surfaceTintColor: Colors.transparent, - ), - inputDecorationTheme: const InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: immichDarkThemePrimaryColor, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: isDark ? Colors.black87 : Colors.white, ), ), - labelStyle: TextStyle( - color: immichDarkThemePrimaryColor, + chipTheme: const ChipThemeData( + side: BorderSide.none, ), - hintStyle: TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.normal, + sliderTheme: const SliderThemeData( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), + trackHeight: 2.0, ), - ), - textSelectionTheme: const TextSelectionThemeData( - cursorColor: immichDarkThemePrimaryColor, - ), -); + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + ), + popupMenuTheme: const PopupMenuThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: + isDark ? colorScheme.surfaceContainer : colorScheme.surface, + labelTextStyle: const WidgetStatePropertyAll( + TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: primaryColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + labelStyle: TextStyle( + color: primaryColor, + ), + hintStyle: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + ), + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: primaryColor, + ), + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: primaryColor, + ), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + labelStyle: TextStyle( + color: primaryColor, + ), + hintStyle: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.normal, + ), + ), + ), + ); +} diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart new file mode 100644 index 0000000000..255ad01247 --- /dev/null +++ b/mobile/lib/utils/openapi_patching.dart @@ -0,0 +1,56 @@ +import 'package:openapi/api.dart'; + +dynamic upgradeDto(dynamic value, String targetType) { + switch (targetType) { + case 'UserPreferencesResponseDto': + if (value is Map) { + addDefault(value, 'download.includeEmbeddedVideos', false); + addDefault(value, 'folders', FoldersResponse().toJson()); + addDefault(value, 'memories', MemoriesResponse().toJson()); + addDefault(value, 'ratings', RatingsResponse().toJson()); + addDefault(value, 'people', PeopleResponse().toJson()); + addDefault(value, 'tags', TagsResponse().toJson()); + } + break; + case 'ServerConfigDto': + if (value is Map) { + addDefault( + value, + 'mapLightStyleUrl', + 'https://tiles.immich.cloud/v1/style/light.json', + ); + addDefault( + value, + 'mapDarkStyleUrl', + 'https://tiles.immich.cloud/v1/style/dark.json', + ); + } + case 'UserResponseDto': + if (value is Map) { + addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + } + break; + case 'UserAdminResponseDto': + if (value is Map) { + addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + } + break; + } +} + +addDefault(dynamic value, String keys, dynamic defaultValue) { + // Loop through the keys and assign the default value if the key is not present + List keyList = keys.split('.'); + dynamic current = value; + + for (int i = 0; i < keyList.length - 1; i++) { + if (current[keyList[i]] == null) { + current[keyList[i]] = {}; + } + current = current[keyList[i]]; + } + + if (current[keyList.last] == null) { + current[keyList.last] = defaultValue; + } +} diff --git a/mobile/lib/utils/provider_utils.dart b/mobile/lib/utils/provider_utils.dart new file mode 100644 index 0000000000..3eac55089d --- /dev/null +++ b/mobile/lib/utils/provider_utils.dart @@ -0,0 +1,16 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/activity_api.repository.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; + +void invalidateAllApiRepositoryProviders(WidgetRef ref) { + ref.invalidate(userApiRepositoryProvider); + ref.invalidate(activityApiRepositoryProvider); + ref.invalidate(partnerApiRepositoryProvider); + ref.invalidate(albumApiRepositoryProvider); + ref.invalidate(personApiRepositoryProvider); + ref.invalidate(assetApiRepositoryProvider); +} diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index 6d11923fd8..56160a2efc 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -118,6 +118,7 @@ Future handleEditDateTime( initialTZ: timeZone, initialTZOffset: offset, ); + if (dateTime == null) { return; } @@ -142,10 +143,12 @@ Future handleEditLocation( ); } } + final location = await showLocationPicker( context: context, initialLatLng: initialLatLng, ); + if (location == null) { return; } diff --git a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart index 46fa0b1fe8..6856ae184d 100644 --- a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart +++ b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart @@ -5,7 +5,6 @@ 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/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -27,13 +26,11 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albumService = ref.watch(albumServiceProvider); - final sharedAlbums = ref.watch(sharedAlbumProvider); useEffect( () { // Fetch album updates, e.g., cover image - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.read(albumProvider.notifier).refreshRemoteAlbums(); return null; }, @@ -41,9 +38,9 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { ); void addToAlbum(Album album) async { - final result = await albumService.addAdditionalAssetToAlbum( - assets, + final result = await albumService.addAssets( album, + assets, ); if (result != null) { @@ -107,8 +104,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { onPressed: () { context.pushRoute( CreateAlbumRoute( - isSharedAlbum: false, - initialAssets: assets, + assets: assets, ), ); }, @@ -123,7 +119,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16), sliver: AddToAlbumSliverList( albums: albums, - sharedAlbums: sharedAlbums, + sharedAlbums: albums.where((a) => a.shared).toList(), onAddToAlbum: addToAlbum, ), ), diff --git a/mobile/lib/widgets/album/album_action_outlined_button.dart b/mobile/lib/widgets/album/album_action_filled_button.dart similarity index 70% rename from mobile/lib/widgets/album/album_action_outlined_button.dart rename to mobile/lib/widgets/album/album_action_filled_button.dart index 02676ae6e2..de73307443 100644 --- a/mobile/lib/widgets/album/album_action_outlined_button.dart +++ b/mobile/lib/widgets/album/album_action_filled_button.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class AlbumActionOutlinedButton extends StatelessWidget { +class AlbumActionFilledButton extends StatelessWidget { final VoidCallback? onPressed; final String labelText; final IconData iconData; - const AlbumActionOutlinedButton({ + const AlbumActionFilledButton({ super.key, this.onPressed, required this.labelText, @@ -17,18 +17,13 @@ class AlbumActionOutlinedButton extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 16.0), - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10), + child: FilledButton.icon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(25), ), - side: BorderSide( - width: 1, - color: context.isDarkTheme - ? const Color.fromARGB(255, 63, 63, 63) - : const Color.fromARGB(255, 206, 206, 206), - ), + backgroundColor: context.colorScheme.surfaceContainerHigh, ), icon: Icon( iconData, diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 737e8b383f..b728f2b541 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; class AlbumThumbnailCard extends StatelessWidget { @@ -11,20 +12,20 @@ class AlbumThumbnailCard extends StatelessWidget { /// Whether or not to show the owner of the album (or "Owned") /// in the subtitle of the album final bool showOwner; + final bool showTitle; const AlbumThumbnailCard({ super.key, required this.album, this.onTap, this.showOwner = false, + this.showTitle = true, }); final Album album; @override Widget build(BuildContext context) { - var isDarkTheme = context.isDarkTheme; - return LayoutBuilder( builder: (context, constraints) { var cardSize = constraints.maxWidth; @@ -34,12 +35,13 @@ class AlbumThumbnailCard extends StatelessWidget { height: cardSize, width: cardSize, decoration: BoxDecoration( - color: isDarkTheme ? Colors.grey[800] : Colors.grey[200], + color: context.colorScheme.surfaceContainerHigh, ), child: Center( child: Icon( Icons.no_photography, size: cardSize * .15, + color: context.colorScheme.primary, ), ), ); @@ -65,6 +67,9 @@ class AlbumThumbnailCard extends StatelessWidget { return RichText( overflow: TextOverflow.fade, text: TextSpan( + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), children: [ TextSpan( text: album.assetCount == 1 @@ -72,14 +77,9 @@ class AlbumThumbnailCard extends StatelessWidget { .tr(args: ['${album.assetCount}']) : 'album_thumbnail_card_items' .tr(args: ['${album.assetCount}']), - style: context.textTheme.bodyMedium, ), - if (owner != null) const TextSpan(text: ' · '), - if (owner != null) - TextSpan( - text: owner, - style: context.textTheme.bodyMedium, - ), + if (owner != null) const TextSpan(text: ' • '), + if (owner != null) TextSpan(text: owner), ], ), ); @@ -104,21 +104,23 @@ class AlbumThumbnailCard extends StatelessWidget { : buildAlbumThumbnail(), ), ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - width: cardSize, - child: Text( - album.name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.primaryColor, - fontWeight: FontWeight.w500, + if (showTitle) ...[ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: cardSize, + child: Text( + album.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - buildAlbumTextRow(), + buildAlbumTextRow(), + ], ], ), ), diff --git a/mobile/lib/widgets/album/album_title_text_field.dart b/mobile/lib/widgets/album/album_title_text_field.dart index 8715c0c038..8a5c28d6af 100644 --- a/mobile/lib/widgets/album/album_title_text_field.dart +++ b/mobile/lib/widgets/album/album_title_text_field.dart @@ -20,8 +20,6 @@ class AlbumTitleTextField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isDarkTheme = context.isDarkTheme; - return TextField( onChanged: (v) { if (v.isEmpty) { @@ -35,7 +33,7 @@ class AlbumTitleTextField extends ConsumerWidget { focusNode: albumTitleTextFieldFocusNode, style: TextStyle( fontSize: 28, - color: isDarkTheme ? Colors.grey[300] : Colors.grey[700], + color: context.colorScheme.onSurface, fontWeight: FontWeight.bold, ), controller: albumTitleController, @@ -70,15 +68,12 @@ class AlbumTitleTextField extends ConsumerWidget { borderRadius: BorderRadius.circular(10), ), hintText: 'share_add_title'.tr(), - hintStyle: TextStyle( + hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28, - color: isDarkTheme ? Colors.grey[300] : Colors.grey[700], fontWeight: FontWeight.bold, ), focusColor: Colors.grey[300], - fillColor: isDarkTheme - ? const Color.fromARGB(255, 32, 33, 35) - : Colors.grey[200], + fillColor: context.colorScheme.surfaceContainerHigh, filled: isAlbumTitleTextFieldFocus.value, ), ); diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 6fb58f8082..89528cc4da 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -46,10 +45,8 @@ class AlbumViewerAppbar extends HookConsumerWidget final bool success; if (album.shared) { - success = - await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + success = await ref.watch(albumProvider.notifier).deleteAlbum(album); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context @@ -95,7 +92,7 @@ class AlbumViewerAppbar extends HookConsumerWidget 'action_common_confirm', style: TextStyle( fontWeight: FontWeight.bold, - color: !context.isDarkTheme ? Colors.red : Colors.red[300], + color: context.colorScheme.error, ), ).tr(), ), @@ -113,11 +110,10 @@ class AlbumViewerAppbar extends HookConsumerWidget isProcessing.value = true; bool isSuccess = - await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.watch(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { context.pop(); 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 788c61d8a4..59e09aa050 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -73,24 +73,18 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { splashRadius: 10, ) : null, - enabledBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), - focusedBorder: OutlineInputBorder( - borderSide: const BorderSide(color: Colors.transparent), - borderRadius: BorderRadius.circular(10), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), ), focusColor: Colors.grey[300], - fillColor: context.isDarkTheme - ? const Color.fromARGB(255, 32, 33, 35) - : Colors.grey[200], + fillColor: context.scaffoldBackgroundColor, filled: titleFocusNode.hasFocus, hintText: 'share_add_title'.tr(), - hintStyle: TextStyle( + hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith( fontSize: 28, - color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700], - fontWeight: FontWeight.bold, ), ), ), diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index 060e0bc04e..ec054d08ee 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -4,7 +4,6 @@ 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/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -72,7 +71,8 @@ class ControlBottomAppBar extends HookConsumerWidget { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); - final sharedAlbums = ref.watch(sharedAlbumProvider); + final sharedAlbums = + ref.watch(albumProvider).where((a) => a.shared).toList(); const bottomPadding = 0.20; final scrollController = useDraggableScrollController(); @@ -281,7 +281,7 @@ class ControlBottomAppBar extends HookConsumerWidget { ScrollController scrollController, ) { return Card( - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[100], + color: context.colorScheme.surfaceContainerLow, surfaceTintColor: Colors.transparent, elevation: 18.0, shape: const RoundedRectangleBorder( diff --git a/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart b/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart index 9d26745b16..50b38c2a4a 100644 --- a/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart +++ b/mobile/lib/widgets/asset_grid/disable_multi_select_button.dart @@ -22,12 +22,15 @@ class DisableMultiSelectButton extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 4.0), child: ElevatedButton.icon( onPressed: () => onPressed(), - icon: const Icon(Icons.close_rounded), + icon: Icon( + Icons.close_rounded, + color: context.colorScheme.onPrimary, + ), label: Text( '$selectedItemCount', style: context.textTheme.titleMedium?.copyWith( height: 2.5, - color: context.isDarkTheme ? Colors.black : Colors.white, + color: context.colorScheme.onPrimary, ), ), ), diff --git a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart index 94a01a57c5..746bbde6ef 100644 --- a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart +++ b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart @@ -316,37 +316,39 @@ class DraggableScrollbarState extends State } setState(() { - int firstItemIndex = - widget.itemPositionsListener.itemPositions.value.first.index; + try { + int firstItemIndex = + widget.itemPositionsListener.itemPositions.value.first.index; - if (notification is ScrollUpdateNotification) { - _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; + if (notification is ScrollUpdateNotification) { + _barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent; - if (_barOffset < barMinScrollExtent) { - _barOffset = barMinScrollExtent; - } - if (_barOffset > barMaxScrollExtent) { - _barOffset = barMaxScrollExtent; - } - } - - if (notification is ScrollUpdateNotification || - notification is OverscrollNotification) { - if (_thumbAnimationController.status != AnimationStatus.forward) { - _thumbAnimationController.forward(); + if (_barOffset < barMinScrollExtent) { + _barOffset = barMinScrollExtent; + } + if (_barOffset > barMaxScrollExtent) { + _barOffset = barMaxScrollExtent; + } } - if (itemPos < maxItemCount) { - _currentItem = itemPos; - } + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification) { + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { - _thumbAnimationController.reverse(); - _labelAnimationController.reverse(); - _fadeoutTimer = null; - }); - } + if (itemPosition < maxItemCount) { + _currentItem = itemPosition; + } + + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { + _thumbAnimationController.reverse(); + _labelAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } catch (_) {} }); } @@ -360,25 +362,35 @@ class DraggableScrollbarState extends State widget.scrollStateListener(true); } - int get itemPos { + int get itemPosition { int numberOfItems = widget.child.itemCount; return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt(); } - void _jumpToBarPos() { - if (itemPos > maxItemCount - 1) { + void _jumpToBarPosition() { + if (itemPosition > maxItemCount - 1) { return; } - _currentItem = itemPos; + _currentItem = itemPosition; + + /// If the bar is at the bottom but the item position is still smaller than the max item count (due to rounding error) + /// jump to the end of the list + if (barMaxScrollExtent - _barOffset < 10 && itemPosition < maxItemCount) { + widget.controller.jumpTo( + index: maxItemCount, + ); + + return; + } widget.controller.jumpTo( - index: itemPos, + index: itemPosition, ); } Timer? dragHaltTimer; - int lastTimerPos = 0; + int lastTimerPosition = 0; void _onVerticalDragUpdate(DragUpdateDetails details) { setState(() { @@ -395,8 +407,8 @@ class DraggableScrollbarState extends State _barOffset = barMaxScrollExtent; } - if (itemPos != lastTimerPos) { - lastTimerPos = itemPos; + if (itemPosition != lastTimerPosition) { + lastTimerPosition = itemPosition; dragHaltTimer?.cancel(); widget.scrollStateListener(true); @@ -408,7 +420,7 @@ class DraggableScrollbarState extends State ); } - _jumpToBarPos(); + _jumpToBarPosition(); } }); } @@ -421,7 +433,7 @@ class DraggableScrollbarState extends State }); setState(() { - _jumpToBarPos(); + _jumpToBarPosition(); _isDragInProcess = false; }); diff --git a/mobile/lib/widgets/asset_grid/group_divider_title.dart b/mobile/lib/widgets/asset_grid/group_divider_title.dart index 4c1f468343..3a411c09db 100644 --- a/mobile/lib/widgets/asset_grid/group_divider_title.dart +++ b/mobile/lib/widgets/asset_grid/group_divider_title.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.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'; @@ -74,9 +75,9 @@ class GroupDividerTitle extends HookConsumerWidget { Icons.check_circle_rounded, color: context.primaryColor, ) - : const Icon( + : Icon( Icons.check_circle_outline_rounded, - color: Colors.grey, + color: context.colorScheme.onSurfaceSecondary, ), ), ], 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 906d0e5969..38e499b5de 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -11,6 +11,7 @@ import 'package:flutter/services.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/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; @@ -261,12 +262,14 @@ class ImmichAssetGridViewState extends ConsumerState { shrinkWrap: widget.shrinkWrap, ); - final child = useDragScrolling + final child = (useDragScrolling && ModalRoute.of(context) != null) ? DraggableScrollbar.semicircle( scrollStateListener: dragScrolling, itemPositionsListener: _itemPositionsListener, controller: _itemScrollController, - backgroundColor: context.themeData.hintColor, + backgroundColor: context.isDarkTheme + ? context.colorScheme.primary.darken(amount: .5) + : context.colorScheme.primary, labelTextBuilder: _labelBuilder, padding: appBarOffset() ? const EdgeInsets.only(top: 60) @@ -278,6 +281,7 @@ class ImmichAssetGridViewState extends ConsumerState { child: listWidget, ) : listWidget; + return widget.onRefresh == null ? child : appBarOffset() @@ -525,7 +529,7 @@ class ImmichAssetGridViewState extends ConsumerState { Widget build(BuildContext context) { return PopScope( canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), - onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, + onPopInvokedWithResult: (didPop, _) => !didPop ? _deselectAll() : null, child: Stack( children: [ AssetDragRegion( diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 23ee771627..eeecfa9b58 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -9,9 +9,8 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -131,11 +130,7 @@ class MultiselectGrid extends HookConsumerWidget { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet - // Filter offline assets since we cannot fetch their original file - final liveAssets = selection.value.nonOfflineOnly( - errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), - ); - handleShareAssets(ref, context, liveAssets); + handleShareAssets(ref, context, selection.value); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) @@ -190,11 +185,12 @@ class MultiselectGrid extends HookConsumerWidget { .deleteAssets(toDelete, force: force); if (isDeleted) { - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, - msg: '${selection.value.length} $assetOrAssets $trashOrRemoved', + msg: force + ? 'assets_deleted_permanently' + .tr(args: ["${selection.value.length}"]) + : 'assets_trashed'.tr(args: ["${selection.value.length}"]), gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; @@ -213,11 +209,10 @@ class MultiselectGrid extends HookConsumerWidget { .read(assetProvider.notifier) .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); if (isDeleted) { - final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset'; ImmichToast.show( context: context, - msg: - '${localIds.length} $assetOrAssets removed permanently from your device', + msg: 'assets_removed_permanently_from_device' + .tr(args: ["${localIds.length}"]), gravity: ToastGravity.BOTTOM, ); selectionEnabledHook.value = false; @@ -239,12 +234,12 @@ class MultiselectGrid extends HookConsumerWidget { .read(assetProvider.notifier) .deleteRemoteOnlyAssets(toDelete, force: force); if (isDeleted) { - final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset'; - final trashOrRemoved = force ? 'deleted permanently' : 'trashed'; ImmichToast.show( context: context, - msg: - '${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server', + msg: force + ? 'assets_deleted_permanently_from_server' + .tr(args: ["${toDelete.length}"]) + : 'assets_trashed_from_server'.tr(args: ["${toDelete.length}"]), gravity: ToastGravity.BOTTOM, ); } @@ -276,11 +271,10 @@ class MultiselectGrid extends HookConsumerWidget { if (assets.isEmpty) { return; } - final result = - await ref.read(albumServiceProvider).addAdditionalAssetToAlbum( - assets, - album, - ); + final result = await ref.read(albumServiceProvider).addAssets( + album, + assets, + ); if (result != null) { if (result.alreadyInAlbum.isNotEmpty) { @@ -327,8 +321,7 @@ class MultiselectGrid extends HookConsumerWidget { .createAlbumWithGeneratedName(assets); if (result != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectionEnabledHook.value = false; context.pushRoute(AlbumViewerRoute(albumId: result.id)); @@ -344,11 +337,9 @@ class MultiselectGrid extends HookConsumerWidget { if (!selectionEnabledHook.value || selection.value.length < 2) { return; } - final parent = selection.value.elementAt(0); - selection.value.remove(parent); - await ref.read(assetStackServiceProvider).updateStack( - parent, - childrenToAdd: selection.value.toList(), + + await ref.read(stackServiceProvider).createStack( + selection.value.map((e) => e.remoteId!).toList(), ); } finally { processing.value = false; diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index d9c9aa0566..35013bb595 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; -import 'package:isar/isar.dart'; class ThumbnailImage extends ConsumerWidget { /// The asset to show the thumbnail image for @@ -42,10 +43,10 @@ class ThumbnailImage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final assetContainerColor = context.isDarkTheme - ? Colors.blueGrey - : context.themeData.primaryColorLight; + ? context.primaryColor.darken(amount: 0.6) + : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; Widget buildSelectionIcon(Asset asset) { if (isSelected) { @@ -106,16 +107,16 @@ class ThumbnailImage extends ConsumerWidget { right: 8, child: Row( children: [ - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) Text( - "${asset.stackChildrenCount}", + "${asset.stackCount}", style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), - if (asset.stackChildrenCount > 1) + if (asset.stackCount > 1) const SizedBox( width: 3, ), @@ -130,17 +131,36 @@ class ThumbnailImage extends ConsumerWidget { } Widget buildImage() { - final image = SizedBox( - width: 300, - height: 300, + final image = SizedBox.expand( child: Hero( tag: isFromDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset, - child: ImmichThumbnail( - asset: asset, - height: 250, - width: 250, + child: Stack( + children: [ + SizedBox.expand( + child: ImmichThumbnail( + asset: asset, + height: 250, + width: 250, + ), + ), + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color.fromRGBO(0, 0, 0, 0.1), + Colors.transparent, + Colors.transparent, + Color.fromRGBO(0, 0, 0, 0.1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.3, 0.6, 1], + ), + ), + ), + ], ), ), ); @@ -152,11 +172,8 @@ class ThumbnailImage extends ConsumerWidget { color: canDeselect ? assetContainerColor : Colors.grey, ), child: ClipRRect( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(15.0), - bottomRight: Radius.circular(15.0), - bottomLeft: Radius.circular(15.0), - topLeft: Radius.zero, + borderRadius: const BorderRadius.all( + Radius.circular(15.0), ), child: image, ), @@ -176,7 +193,33 @@ class ThumbnailImage extends ConsumerWidget { ) : const Border(), ), - child: buildImage(), + child: Stack( + children: [ + buildImage(), + if (showStorageIndicator) + Positioned( + right: 8, + bottom: 5, + child: Icon( + storageIcon(asset), + color: Colors.white.withOpacity(.8), + size: 16, + ), + ), + if (asset.isFavorite) + const Positioned( + left: 8, + bottom: 5, + child: Icon( + Icons.favorite, + color: Colors.white, + size: 16, + ), + ), + if (!asset.isImage) buildVideoIcon(), + if (asset.stackCount > 0) buildStackIcon(), + ], + ), ), if (multiselectEnabled) Padding( @@ -186,28 +229,6 @@ class ThumbnailImage extends ConsumerWidget { child: buildSelectionIcon(asset), ), ), - if (showStorageIndicator) - Positioned( - right: 8, - bottom: 5, - child: Icon( - storageIcon(asset), - color: Colors.white, - size: 18, - ), - ), - if (asset.isFavorite) - const Positioned( - left: 8, - bottom: 5, - child: Icon( - Icons.favorite, - color: Colors.white, - size: 18, - ), - ), - if (!asset.isImage) buildVideoIcon(), - if (asset.stackChildrenCount > 0) buildStackIcon(), ], ); } diff --git a/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart b/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart index d762704835..5b12426a50 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class ThumbnailPlaceholder extends StatelessWidget { final EdgeInsets margin; @@ -13,25 +14,20 @@ class ThumbnailPlaceholder extends StatelessWidget { this.height = 250, }); - static const _brightColors = [ - Color(0xFFF1F3F4), - Color(0xFFB4B6B8), - ]; - - static const _darkColors = [ - Color(0xFF3B3F42), - Color(0xFF2B2F32), - ]; - @override Widget build(BuildContext context) { + var gradientColors = [ + context.colorScheme.surfaceContainer, + context.colorScheme.surfaceContainer.darken(amount: .1), + ]; + return Container( width: width, height: height, margin: margin, decoration: BoxDecoration( gradient: LinearGradient( - colors: context.isDarkTheme ? _darkColors : _brightColors, + colors: gradientColors, begin: Alignment.topCenter, end: Alignment.bottomCenter, ), diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 45867ad11d..f550857b9d 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -6,16 +6,17 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/services/asset_stack.service.dart'; +import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -49,11 +50,10 @@ class BottomGalleryBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; - final stack = showStack && asset.stackChildrenCount > 0 + final stackItems = showStack && asset.stackCount > 0 ? ref.watch(assetStackStateProvider(asset)) : []; - final stackElements = showStack ? [asset, ...stack] : []; - bool isParent = stackIndex == -1 || stackIndex == 0; + bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); @@ -61,58 +61,6 @@ class BottomGalleryBar extends ConsumerWidget { navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name; final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false; - // !!!! itemsList and actionlist should always be in sync - final itemsList = [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - if (asset.isImage) - BottomNavigationBarItem( - icon: const Icon(Icons.tune_outlined), - label: 'control_bottom_app_bar_edit'.tr(), - tooltip: 'control_bottom_app_bar_edit'.tr(), - ), - if (isOwner) - asset.isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - if (isOwner && stack.isNotEmpty) - BottomNavigationBarItem( - icon: const Icon(Icons.burst_mode_outlined), - label: 'control_bottom_app_bar_stack'.tr(), - tooltip: 'control_bottom_app_bar_stack'.tr(), - ), - if (isOwner && !isInAlbum) - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - if (!isOwner) - BottomNavigationBarItem( - icon: const Icon(Icons.download_outlined), - label: 'download'.tr(), - tooltip: 'download'.tr(), - ), - if (isInAlbum) - BottomNavigationBarItem( - icon: const Icon(Icons.remove_circle_outline), - label: 'album_viewer_appbar_share_remove'.tr(), - tooltip: 'album_viewer_appbar_share_remove'.tr(), - ), - ]; void removeAssetFromStack() { if (stackIndex > 0 && showStack) { @@ -128,7 +76,7 @@ class BottomGalleryBar extends ConsumerWidget { {asset}, force: force, ); - if (isDeleted && isParent) { + if (isDeleted && isStackPrimaryAsset) { // Workaround for asset remaining in the gallery renderList.deleteAsset(asset); @@ -150,7 +98,7 @@ class BottomGalleryBar extends ConsumerWidget { final isDeleted = await onDelete(false); if (isDeleted) { // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && asset.isRemote && isParent) { + if (context.mounted && asset.isRemote && isStackPrimaryAsset) { ImmichToast.show( durationInSecond: 1, context: context, @@ -179,6 +127,16 @@ class BottomGalleryBar extends ConsumerWidget { ); } + unStack() async { + if (asset.stackId == null) { + return; + } + + await ref + .read(stackServiceProvider) + .deleteStack(asset.stackId!, [asset, ...stackItems]); + } + void showStackActionItems() { showModalBottomSheet( context: context, @@ -190,74 +148,13 @@ class BottomGalleryBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements.elementAt(stackIndex), - ); - ctx.pop(); - context.maybePop(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - asset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [asset], - ); - ctx.pop(); - context.maybePop(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: [ - stackElements.elementAt(stackIndex), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), ListTile( leading: const Icon( Icons.filter_none_outlined, size: 18, ), onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - asset, - childrenToRemove: stack, - ); + await unStack(); ctx.pop(); context.maybePop(); }, @@ -284,30 +181,26 @@ class BottomGalleryBar extends ConsumerWidget { ); return; } - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_edit_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } + final image = Image(image: ImmichImage.imageProvider(asset: asset)); + Navigator.of(context).push( MaterialPageRoute( - builder: (context) => - EditImagePage(asset: asset), // Send the Asset object + builder: (context) => EditImagePage( + asset: asset, + image: image, + isEdited: false, + ), ), ); } handleArchive() { ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { + if (isStackPrimaryAsset) { context.maybePop(); return; } @@ -328,7 +221,7 @@ class BottomGalleryBar extends ConsumerWidget { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); @@ -337,9 +230,7 @@ class BottomGalleryBar extends ConsumerWidget { handleRemoveFromAlbum() async { final album = ref.read(currentAlbumProvider); final bool isSuccess = album != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(album, [asset]); + await ref.read(albumProvider.notifier).removeAsset(album, [asset]); if (isSuccess) { // Workaround for asset remaining in the gallery @@ -366,16 +257,71 @@ class BottomGalleryBar extends ConsumerWidget { } } - List actionslist = [ - (_) => shareAsset(), - if (asset.isImage) (_) => handleEdit(), - if (isOwner) (_) => handleArchive(), - if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), - if (isOwner) (_) => handleDelete(), - if (!isOwner) (_) => handleDownload(), - if (isInAlbum) (_) => handleRemoveFromAlbum(), + final List> albumActions = [ + { + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ): (_) => shareAsset(), + }, + if (asset.isImage) + { + BottomNavigationBarItem( + icon: const Icon(Icons.tune_outlined), + label: 'control_bottom_app_bar_edit'.tr(), + tooltip: 'control_bottom_app_bar_edit'.tr(), + ): (_) => handleEdit(), + }, + if (isOwner) + { + asset.isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ): (_) => handleArchive(), + }, + if (isOwner && asset.stackCount > 0) + { + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ): (_) => showStackActionItems(), + }, + if (isOwner && !isInAlbum) + { + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ): (_) => handleDelete(), + }, + if (!isOwner) + { + BottomNavigationBarItem( + icon: const Icon(Icons.download_outlined), + label: 'control_bottom_app_bar_download'.tr(), + tooltip: 'control_bottom_app_bar_download'.tr(), + ): (_) => handleDownload(), + }, + if (isInAlbum) + { + BottomNavigationBarItem( + icon: const Icon(Icons.remove_circle_outline), + label: 'album_viewer_appbar_share_remove'.tr(), + tooltip: 'album_viewer_appbar_share_remove'.tr(), + ): (_) => handleRemoveFromAlbum(), + }, ]; - return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -407,11 +353,10 @@ class BottomGalleryBar extends ConsumerWidget { unselectedItemColor: Colors.white, showSelectedLabels: true, showUnselectedLabels: true, - items: itemsList, + items: + albumActions.map((e) => e.keys.first).toList(growable: false), onTap: (index) { - if (index < actionslist.length) { - actionslist[index].call(index); - } + albumActions[index].values.first.call(index); }, ), ], diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 7422e43335..3fdd40130a 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -5,8 +5,10 @@ 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/entities/asset.entity.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'; -import 'package:immich_mobile/services/asset_description.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -23,32 +25,33 @@ class DescriptionInput extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final textColor = context.isDarkTheme ? Colors.white : Colors.black; final controller = useTextEditingController(); final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionServiceProvider); - + final assetService = ref.watch(assetServiceProvider); final owner = ref.watch(currentUserProvider); final hasError = useState(false); + final assetWithExif = ref.watch(assetDetailProvider(asset)); useEffect( () { - controller.text = exifInfo?.description ?? ''; - isTextEmpty.value = exifInfo?.description?.isEmpty ?? true; + assetService + .getDescription(asset) + .then((value) => controller.text = value); return null; }, - [exifInfo?.description], + [assetWithExif.value], ); submitDescription(String description) async { hasError.value = false; try { - await descriptionProvider.setDescription( + await assetService.setDescription( asset, description, ); + controller.text = description; } catch (error, stack) { hasError.value = true; _log.severe("Error updating description", error, stack); @@ -71,7 +74,7 @@ class DescriptionInput extends HookConsumerWidget { }, icon: Icon( Icons.cancel_rounded, - color: Colors.grey[500], + color: context.colorScheme.onSurfaceSecondary, ), splashRadius: 10, ); @@ -100,10 +103,12 @@ class DescriptionInput extends HookConsumerWidget { decoration: InputDecoration( hintText: 'description_input_hint_text'.tr(), border: InputBorder.none, - hintStyle: context.textTheme.labelLarge?.copyWith( - color: textColor.withOpacity(0.5), - ), suffixIcon: suffixIcon, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, ), ); } diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_date_time.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_date_time.dart new file mode 100644 index 0000000000..e29da52280 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_date_time.dart @@ -0,0 +1,54 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/extensions/duration_extensions.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/utils/selection_handlers.dart'; + +class AssetDateTime extends ConsumerWidget { + final Asset asset; + + const AssetDateTime({super.key, required this.asset}); + + String getDateTimeString(Asset a) { + final (deltaTime, timeZone) = a.getTZAdjustedTimeAndOffset(); + final date = DateFormat.yMMMEd().format(deltaTime); + final time = DateFormat.jm().format(deltaTime); + return '$date • $time GMT${timeZone.formatAsOffset()}'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final watchedAsset = ref.watch(assetDetailProvider(asset)); + String formattedDateTime = getDateTimeString(asset); + + void editDateTime() async { + await handleEditDateTime(ref, context, [asset]); + + if (watchedAsset.value != null) { + formattedDateTime = getDateTimeString(watchedAsset.value!); + } + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formattedDateTime, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (asset.isRemote) + IconButton( + onPressed: editDateTime, + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart new file mode 100644 index 0000000000..a78a309512 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_details.dart @@ -0,0 +1,44 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/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'; + +class AssetDetails extends ConsumerWidget { + final Asset asset; + final ExifInfo? exifInfo; + + const AssetDetails({ + super.key, + required this.asset, + this.exifInfo, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetWithExif = ref.watch(assetDetailProvider(asset)); + final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; + + return Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "exif_bottom_sheet_details", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + FileInfo(asset: asset), + if (exifInfo?.make != null) CameraInfo(exifInfo: exifInfo!), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart new file mode 100644 index 0000000000..364b568d0a --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart @@ -0,0 +1,106 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/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; + + const AssetLocation({ + super.key, + required this.asset, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetWithExif = ref.watch(assetDetailProvider(asset)); + final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + + void editLocation() { + handleEditLocation(ref, context, [assetWithExif.value ?? asset]); + } + + // Guard no lat/lng + if (!hasCoordinates) { + return asset.isRemote + ? ListTile( + minLeadingWidth: 0, + contentPadding: const EdgeInsets.all(0), + leading: const Icon(Icons.location_on), + title: Text( + "exif_bottom_sheet_location_add", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + onTap: editLocation, + ) + : const SizedBox.shrink(); + } + + Widget getLocationName() { + if (exifInfo == null) { + return const SizedBox.shrink(); + } + + final cityName = exifInfo.city; + final stateName = exifInfo.state; + + bool hasLocationName = (cityName != null && stateName != null); + + return hasLocationName + ? Text( + "$cityName, $stateName", + style: context.textTheme.labelLarge, + ) + : const SizedBox.shrink(); + } + + return Padding( + padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "exif_bottom_sheet_location", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + if (asset.isRemote) + IconButton( + onPressed: editLocation, + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ), + asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16), + ExifMap( + exifInfo: exifInfo!, + markerId: asset.remoteId, + ), + const SizedBox(height: 16), + getLocationName(), + Text( + "${exifInfo.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(150), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart new file mode 100644 index 0000000000..e6720e0255 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/camera_info.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class CameraInfo extends StatelessWidget { + final ExifInfo exifInfo; + + const CameraInfo({ + super.key, + required this.exifInfo, + }); + + @override + Widget build(BuildContext context) { + final textColor = context.isDarkTheme ? Colors.white : Colors.black; + return ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), + title: Text( + "${exifInfo.make} ${exifInfo.model}", + style: context.textTheme.labelLarge, + ), + subtitle: exifInfo.f != null || + exifInfo.exposureSeconds != null || + exifInfo.mm != null || + exifInfo.iso != null + ? Text( + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", + style: context.textTheme.bodySmall, + ) + : null, + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart new file mode 100644 index 0000000000..db9dafebcb --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/widgets/asset_viewer/description_input.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_date_time.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_details.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_location.dart'; +import 'package:immich_mobile/widgets/asset_viewer/detail_panel/people_info.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; + +class DetailPanel extends HookConsumerWidget { + final Asset asset; + + const DetailPanel({super.key, required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + AssetDateTime(asset: asset), + asset.isRemote + ? DescriptionInput(asset: asset) + : const SizedBox.shrink(), + PeopleInfo(asset: asset), + AssetLocation(asset: asset), + AssetDetails(asset: asset), + ], + ), + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart similarity index 63% rename from mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart rename to mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index a2a78b103c..7878404273 100644 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -8,13 +8,11 @@ import 'package:url_launcher/url_launcher.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; - final String formattedDateTime; final String? markerId; const ExifMap({ super.key, required this.exifInfo, - required this.formattedDateTime, this.markerId = 'marker', }); @@ -37,7 +35,7 @@ class ExifMap extends StatelessWidget { host: '$latitude,$longitude', queryParameters: { 'z': '$zoomLevel', - 'q': '$latitude,$longitude($formattedDateTime)', + 'q': '$latitude,$longitude', }, ); if (await canLaunchUrl(uri)) { @@ -46,7 +44,7 @@ class ExifMap extends StatelessWidget { } else if (Platform.isIOS) { var params = { 'll': '$latitude,$longitude', - 'q': formattedDateTime, + 'q': '$latitude,$longitude', 'z': '$zoomLevel', }; Uri uri = Uri.https('maps.apple.com', '/', params); @@ -63,32 +61,29 @@ class ExifMap extends StatelessWidget { ); } - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - return MapThumbnail( - centre: LatLng( - exifInfo.latitude ?? 0, - exifInfo.longitude ?? 0, - ), - height: 150, - width: constraints.maxWidth, - zoom: 12.0, - assetMarkerRemoteId: markerId, - onTap: (tapPosition, latLong) async { - Uri? uri = await createCoordinatesUri(); + return LayoutBuilder( + builder: (context, constraints) { + return MapThumbnail( + centre: LatLng( + exifInfo.latitude ?? 0, + exifInfo.longitude ?? 0, + ), + height: 150, + width: constraints.maxWidth, + zoom: 12.0, + assetMarkerRemoteId: markerId, + onTap: (tapPosition, latLong) async { + Uri? uri = await createCoordinatesUri(); - if (uri == null) { - return; - } + if (uri == null) { + return; + } - debugPrint('Opening Map Uri: $uri'); - launchUrl(uri); - }, - ); - }, - ), + debugPrint('Opening Map Uri: $uri'); + launchUrl(uri); + }, + ); + }, ); } } diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_image_properties.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart similarity index 95% rename from mobile/lib/widgets/asset_viewer/exif_sheet/exif_image_properties.dart rename to mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 6f268c3d71..3c650bdc6a 100644 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_image_properties.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -3,10 +3,10 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; -class ExifImageProperties extends StatelessWidget { +class FileInfo extends StatelessWidget { final Asset asset; - const ExifImageProperties({ + const FileInfo({ super.key, required this.asset, }); diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart new file mode 100644 index 0000000000..f917f03b37 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/detail_panel/people_info.dart @@ -0,0 +1,102 @@ +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart'; +import 'package:immich_mobile/models/search/search_curated_content.model.dart'; +import 'package:immich_mobile/widgets/search/curated_people_row.dart'; +import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; + +class PeopleInfo extends ConsumerWidget { + final Asset asset; + final EdgeInsets? padding; + + const PeopleInfo({super.key, required this.asset, this.padding}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final peopleProvider = + ref.watch(assetPeopleNotifierProvider(asset).notifier); + final people = ref + .watch(assetPeopleNotifierProvider(asset)) + .value + ?.where((p) => !p.isHidden); + final double imageSize = math.min(context.width / 3, 150); + + showPersonNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ).then((_) { + // ensure the people list is up-to-date. + peopleProvider.refresh(); + }); + } + + final curatedPeople = people + ?.map((p) => SearchCuratedContent(id: p.id, label: p.name)) + .toList() ?? + []; + + return AnimatedCrossFade( + crossFadeState: (people?.isEmpty ?? true) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: Container(), + secondChild: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + children: [ + Padding( + padding: padding ?? EdgeInsets.zero, + child: Align( + alignment: Alignment.topLeft, + child: Text( + "exif_bottom_sheet_people", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ), + SizedBox( + height: imageSize, + child: Padding( + padding: const EdgeInsets.only(top: 16.0), + child: CuratedPeopleRow( + padding: padding, + content: curatedPeople, + onTap: (content, index) { + context + .pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ) + .then((_) => peopleProvider.refresh()); + }, + onNameTap: (person, index) => { + showPersonNameEditModel(person.id, person.label), + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart deleted file mode 100644 index a0505e3d48..0000000000 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_bottom_sheet.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asset_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/widgets/asset_viewer/description_input.dart'; -import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_detail.dart'; -import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_image_properties.dart'; -import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_location.dart'; -import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_people.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/utils/selection_handlers.dart'; - -class ExifBottomSheet extends HookConsumerWidget { - final Asset asset; - - const ExifBottomSheet({super.key, required this.asset}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final assetWithExif = ref.watch(assetDetailProvider(asset)); - var textColor = context.isDarkTheme ? Colors.white : Colors.black; - final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; - // Format the date time with the timezone - final (dt, timeZone) = - (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); - final date = DateFormat.yMMMEd().format(dt); - final time = DateFormat.jm().format(dt); - - String formattedDateTime = '$date • $time GMT${timeZone.formatAsOffset()}'; - - final dateWidget = Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - formattedDateTime, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - if (asset.isRemote) - IconButton( - onPressed: () => handleEditDateTime( - ref, - context, - [assetWithExif.value ?? asset], - ), - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ); - - return SingleChildScrollView( - padding: const EdgeInsets.only( - bottom: 50, - ), - child: LayoutBuilder( - builder: (context, constraints) { - final horizontalPadding = constraints.maxWidth > 600 ? 24.0 : 16.0; - if (constraints.maxWidth > 600) { - // Two column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: Column( - children: [ - dateWidget, - if (asset.isRemote) - DescriptionInput(asset: asset, exifInfo: exifInfo), - ], - ), - ), - ExifPeople( - asset: asset, - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: ExifLocation( - asset: asset, - exifInfo: exifInfo, - editLocation: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - formattedDateTime: formattedDateTime, - ), - ), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ExifDetail(asset: asset, exifInfo: exifInfo), - ), - ), - ], - ), - ), - ], - ); - } - - // One column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - child: Column( - children: [ - dateWidget, - if (asset.isRemote) - DescriptionInput(asset: asset, exifInfo: exifInfo), - Padding( - padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0), - child: ExifLocation( - asset: asset, - exifInfo: exifInfo, - editLocation: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - formattedDateTime: formattedDateTime, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: ExifPeople( - asset: asset, - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - ), - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - "exif_bottom_sheet_details", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color - ?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - ExifImageProperties(asset: asset), - if (exifInfo?.make != null) - ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.camera, - color: textColor.withAlpha(200), - ), - title: Text( - "${exifInfo!.make} ${exifInfo.model}", - style: context.textTheme.labelLarge, - ), - subtitle: exifInfo.f != null || - exifInfo.exposureSeconds != null || - exifInfo.mm != null || - exifInfo.iso != null - ? Text( - "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", - style: context.textTheme.bodySmall, - ) - : null, - ), - ], - ), - ), - const SizedBox(height: 50), - ], - ); - }, - ), - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart deleted file mode 100644 index acd0d2d202..0000000000 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart +++ /dev/null @@ -1,60 +0,0 @@ -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/widgets/asset_viewer/exif_sheet/exif_image_properties.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; - -class ExifDetail extends StatelessWidget { - final Asset asset; - final ExifInfo? exifInfo; - - const ExifDetail({ - super.key, - required this.asset, - this.exifInfo, - }); - - @override - Widget build(BuildContext context) { - final textColor = context.isDarkTheme ? Colors.white : Colors.black; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - "exif_bottom_sheet_details", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - ExifImageProperties(asset: asset), - if (exifInfo?.make != null) - ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.camera, - color: textColor.withAlpha(200), - ), - title: Text( - "${exifInfo?.make} ${exifInfo?.model}", - style: context.textTheme.labelLarge, - ), - subtitle: exifInfo?.f != null || - exifInfo?.exposureSeconds != null || - exifInfo?.mm != null || - exifInfo?.iso != null - ? Text( - "ƒ/${exifInfo?.fNumber} ${exifInfo?.exposureTime} ${exifInfo?.focalLength} mm ISO ${exifInfo?.iso ?? ''} ", - style: context.textTheme.bodySmall, - ) - : null, - ), - ], - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart deleted file mode 100644 index 713a75c06e..0000000000 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart +++ /dev/null @@ -1,105 +0,0 @@ -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/widgets/asset_viewer/exif_sheet/exif_map.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; - -class ExifLocation extends StatelessWidget { - final Asset asset; - final ExifInfo? exifInfo; - final void Function() editLocation; - final String formattedDateTime; - - const ExifLocation({ - super.key, - required this.asset, - required this.exifInfo, - required this.editLocation, - required this.formattedDateTime, - }); - - @override - Widget build(BuildContext context) { - final hasCoordinates = exifInfo?.hasCoordinates ?? false; - // Guard no lat/lng - if (!hasCoordinates) { - return asset.isRemote - ? ListTile( - minLeadingWidth: 0, - contentPadding: const EdgeInsets.all(0), - leading: const Icon(Icons.location_on), - title: Text( - "exif_bottom_sheet_location_add", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), - onTap: editLocation, - ) - : const SizedBox.shrink(); - } - - return Column( - children: [ - // Location - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "exif_bottom_sheet_location", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - if (asset.isRemote) - IconButton( - onPressed: editLocation, - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ), - ExifMap( - exifInfo: exifInfo!, - formattedDateTime: formattedDateTime, - markerId: asset.remoteId, - ), - RichText( - text: TextSpan( - style: context.textTheme.labelLarge, - children: [ - if (exifInfo != null && exifInfo?.city != null) - TextSpan( - text: exifInfo!.city, - ), - if (exifInfo != null && - exifInfo?.city != null && - exifInfo?.state != null) - const TextSpan( - text: ", ", - ), - if (exifInfo != null && exifInfo?.state != null) - TextSpan( - text: exifInfo!.state, - ), - ], - ), - ), - Text( - "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(150), - ), - ), - ], - ), - ], - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart b/mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart deleted file mode 100644 index 532a74dd2a..0000000000 --- a/mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:math' as math; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/widgets/search/curated_people_row.dart'; -import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; - -class ExifPeople extends ConsumerWidget { - final Asset asset; - final EdgeInsets? padding; - - const ExifPeople({super.key, required this.asset, this.padding}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final peopleProvider = - ref.watch(assetPeopleNotifierProvider(asset).notifier); - final people = ref - .watch(assetPeopleNotifierProvider(asset)) - .value - ?.where((p) => !p.isHidden); - final double imageSize = math.min(context.width / 3, 150); - - showPersonNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ).then((_) { - // ensure the people list is up-to-date. - peopleProvider.refresh(); - }); - } - - if (people?.isEmpty ?? true) { - // Empty list or loading - return Container(); - } - - final curatedPeople = people - ?.map((p) => SearchCuratedContent(id: p.id, label: p.name)) - .toList() ?? - []; - - return Column( - children: [ - Padding( - padding: padding ?? EdgeInsets.zero, - child: Align( - alignment: Alignment.topLeft, - child: Text( - "exif_bottom_sheet_people", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - ), - SizedBox( - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: CuratedPeopleRow( - padding: padding, - content: curatedPeople, - onTap: (content, index) { - context - .pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ) - .then((_) => peopleProvider.refresh()); - }, - onNameTap: (person, index) => { - showPersonNameEditModel(person.id, person.label), - }, - ), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 9bd6ff1102..f400224e0a 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -1,10 +1,11 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -56,7 +57,7 @@ class GalleryAppBar extends ConsumerWidget { if (result && context.mounted) { ImmichToast.show( context: context, - msg: 'asset restored successfully', + msg: 'asset_restored_successfully'.tr(), gravity: ToastGravity.BOTTOM, ); } @@ -92,6 +93,10 @@ class GalleryAppBar extends ConsumerWidget { ); } + handleDownloadAsset() { + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); + } + return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -108,13 +113,7 @@ class GalleryAppBar extends ConsumerWidget { onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, - onDownloadPressed: asset.isLocal - ? null - : () => - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset, - context, - ), + onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, onToggleMotionVideo: onToggleMotionVideo, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, 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 70fd5e3b89..984b61f50c 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -178,12 +178,12 @@ class TopControlAppBar extends HookConsumerWidget { actionsIconTheme: const IconThemeData( size: iconSize, ), + shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) - buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index e9349bd69e..7b04855809 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { final AvailableAlbum album; - const AlbumInfoCard({super.key, required this.album}); + const AlbumInfoCard({ + super.key, + required this.album, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); final isDarkTheme = context.isDarkTheme; @@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, onDoubleTap: () { @@ -171,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.only(top: 2.0), - child: FutureBuilder( - builder: ((context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data.toString() + - (album.isAll - ? " (${'backup_all'.tr()})" - : ""), - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - ); - } - return const Text("0"); - }), - future: album.assetCount, + child: Text( + album.assetCount.toString() + + (album.isAll ? " (${'backup_all'.tr()})" : ""), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), ), ), ], @@ -196,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget { IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: album.albumEntity), + AlbumPreviewRoute(album: album.album), ); }, icon: Icon( diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index 2e10fe0b75..a263c004bd 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -1,13 +1,15 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { @@ -21,15 +23,9 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); - var assetCount = useState(0); - - useEffect( - () { - album.assetCount.then((value) => assetCount.value = value); - return null; - }, - [album], - ); + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); buildTileColor() { if (isSelected) { @@ -47,22 +43,22 @@ class AlbumInfoListTile extends HookConsumerWidget { buildIcon() { if (isSelected) { - return const Icon( + return Icon( Icons.check_circle_rounded, - color: Colors.green, + color: context.colorScheme.primary, ); } if (isExcluded) { - return const Icon( + return Icon( Icons.remove_circle_rounded, - color: Colors.red, + color: context.colorScheme.error, ); } return Icon( Icons.circle, - color: context.isDarkTheme ? Colors.grey[400] : Colors.black45, + color: context.colorScheme.surfaceContainerHighest, ); } @@ -98,6 +94,9 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { ref.read(backupProvider.notifier).addAlbumForBackup(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } } }, leading: buildIcon(), @@ -108,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget { fontWeight: FontWeight.bold, ), ), - subtitle: Text(assetCount.value.toString()), + subtitle: Text(album.assetCount.toString()), trailing: IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: album.albumEntity), + AlbumPreviewRoute(album: album.album), ); }, icon: Icon( diff --git a/mobile/lib/widgets/backup/backup_info_card.dart b/mobile/lib/widgets/backup/backup_info_card.dart index e1b56a970a..58fc89cb65 100644 --- a/mobile/lib/widgets/backup/backup_info_card.dart +++ b/mobile/lib/widgets/backup/backup_info_card.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class BackupInfoCard extends StatelessWidget { final String title; @@ -19,9 +20,7 @@ class BackupInfoCard extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), // if you need this side: BorderSide( - color: context.isDarkTheme - ? const Color.fromARGB(255, 56, 56, 56) - : Colors.black12, + color: context.colorScheme.outlineVariant, width: 1, ), ), @@ -38,7 +37,9 @@ class BackupInfoCard extends StatelessWidget { padding: const EdgeInsets.only(top: 4.0, right: 18.0), child: Text( subtitle, - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ), ), trailing: Column( diff --git a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart index 2520acedf1..f2f84e271f 100644 --- a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart +++ b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart @@ -2,17 +2,19 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; class CurrentUploadingAssetInfoBox extends HookConsumerWidget { const CurrentUploadingAssetInfoBox({super.key}); @@ -82,22 +84,20 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { Widget buildAssetInfoTable() { return Table( border: TableBorder.all( - color: context.themeData.primaryColorLight, + color: context.colorScheme.outlineVariant, width: 1, ), children: [ TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[100], - ), children: [ TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( 'backup_controller_page_filename', style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -109,17 +109,15 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ], ), TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[200], - ), children: [ TableCell( verticalAlignment: TableCellVerticalAlignment.middle, child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( "backup_controller_page_created", style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -131,16 +129,14 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ], ), TableRow( - decoration: const BoxDecoration( - // color: Colors.grey[100], - ), children: [ TableCell( child: Padding( padding: const EdgeInsets.all(6.0), - child: const Text( + child: Text( "backup_controller_page_id", style: TextStyle( + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, fontSize: 10.0, ), @@ -153,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ); } - buildAssetThumbnail() async { - var assetEntity = await AssetEntity.fromId(asset.id); - - if (assetEntity != null) { - return assetEntity.thumbnailDataWithSize( - const ThumbnailSize(500, 500), - quality: 100, - ); - } - } - buildiCloudDownloadProgerssBar() { if (asset.iCloudAsset != null && asset.iCloudAsset!) { return Padding( @@ -181,8 +166,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: LinearProgressIndicator( minHeight: 10.0, value: uploadProgress / 100.0, - backgroundColor: Colors.grey, - color: context.primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), ), Text( @@ -214,8 +198,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: LinearProgressIndicator( minHeight: 10.0, value: uploadProgress / 100.0, - backgroundColor: Colors.grey, - color: context.primaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), ), ), Text( @@ -246,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ); } - return FutureBuilder( - future: buildAssetThumbnail(), + return FutureBuilder( + future: ref.read(assetMediaRepositoryProvider).get(asset.id), builder: (context, thumbnail) => ListTile( isThreeLine: true, leading: AnimatedCrossFade( @@ -257,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: thumbnail.hasData ? ClipRRect( borderRadius: BorderRadius.circular(5), - child: Image.memory( - thumbnail.data!, - fit: BoxFit.cover, + child: ImmichThumbnail( + asset: thumbnail.data, width: 50, height: 50, ), 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 fbcfd64713..cd694336bc 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 @@ -88,7 +88,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { buildSettingButton() { return buildActionButton( - Icons.settings_rounded, + Icons.settings_outlined, "profile_drawer_settings", () => context.pushRoute(const SettingsRoute()), ); @@ -146,9 +146,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 4), decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, ), child: ListTile( minLeadingWidth: 50, @@ -171,10 +169,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(top: 8.0), child: LinearProgressIndicator( - minHeight: 5.0, + minHeight: 10.0, value: percentage, - backgroundColor: Colors.grey, - color: theme.primaryColor, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), ), ), Padding( @@ -239,36 +237,40 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); } - return Dialog( - clipBehavior: Clip.hardEdge, - alignment: Alignment.topCenter, - insetPadding: EdgeInsets.only( - top: isHorizontal ? 20 : 40, - left: horizontalPadding, - right: horizontalPadding, - bottom: isHorizontal ? 20 : 100, - ), - backgroundColor: theme.cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: SizedBox( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(20), - child: buildTopRow(), - ), - const AppBarProfileInfoBox(), - buildStorageInformation(), - const AppBarServerInfo(), - buildAppLogButton(), - buildSettingButton(), - buildSignOutButton(), - buildFooter(), - ], + return Dismissible( + direction: DismissDirection.down, + onDismissed: (_) => Navigator.of(context).pop(), + key: const Key('app_bar_dialog'), + child: Dialog( + clipBehavior: Clip.hardEdge, + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.only( + top: isHorizontal ? 20 : 40, + left: horizontalPadding, + right: horizontalPadding, + bottom: isHorizontal ? 20 : 100, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: SizedBox( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + child: buildTopRow(), + ), + const AppBarProfileInfoBox(), + buildStorageInformation(), + const AppBarServerInfo(), + buildAppLogButton(), + buildSettingButton(), + buildSignOutButton(), + buildFooter(), + ], + ), ), ), ), 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 5e768f3241..a40dcf914e 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -79,9 +80,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { child: Container( width: double.infinity, decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), @@ -99,9 +98,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { bottom: -5, right: -8, child: Material( - color: context.isDarkTheme - ? Colors.blueGrey[800] - : Colors.white, + color: context.colorScheme.surfaceContainerHighest, elevation: 3, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50.0), @@ -129,7 +126,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { subtitle: Text( authState.userEmail, style: context.textTheme.bodySmall?.copyWith( - color: context.textTheme.bodySmall?.color?.withAlpha(200), + color: context.colorScheme.onSurfaceSecondary, ), ), ), 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 0beb45c49f..8cab0bd72f 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -42,9 +43,7 @@ class AppBarServerInfo extends HookConsumerWidget { padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0), child: Container( decoration: BoxDecoration( - color: context.isDarkTheme - ? context.scaffoldBackgroundColor - : const Color.fromARGB(255, 225, 229, 240), + color: context.colorScheme.surface, borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(10), bottomRight: Radius.circular(10), @@ -71,10 +70,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -100,8 +96,7 @@ class AppBarServerInfo extends HookConsumerWidget { "${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), @@ -111,10 +106,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -142,8 +134,7 @@ class AppBarServerInfo extends HookConsumerWidget { : "--", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), @@ -153,10 +144,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -197,8 +185,7 @@ class AppBarServerInfo extends HookConsumerWidget { getServerUrl() ?? '--', style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis, ), @@ -211,10 +198,7 @@ class AppBarServerInfo extends HookConsumerWidget { ), const Padding( padding: EdgeInsets.symmetric(horizontal: 10), - child: Divider( - color: Color.fromARGB(101, 201, 201, 201), - thickness: 1, - ), + child: Divider(thickness: 1), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -255,8 +239,7 @@ class AppBarServerInfo extends HookConsumerWidget { : "--", style: TextStyle( fontSize: contentFontSize, - color: context.textTheme.labelSmall?.color - ?.withOpacity(0.5), + color: context.colorScheme.onSurfaceSecondary, fontWeight: FontWeight.bold, ), ), diff --git a/mobile/lib/widgets/common/confirm_dialog.dart b/mobile/lib/widgets/common/confirm_dialog.dart index 5f24f75d51..5e043cf8de 100644 --- a/mobile/lib/widgets/common/confirm_dialog.dart +++ b/mobile/lib/widgets/common/confirm_dialog.dart @@ -47,7 +47,7 @@ class ConfirmDialog extends StatelessWidget { child: Text( ok, style: TextStyle( - color: Colors.red[400], + color: context.colorScheme.error, fontWeight: FontWeight.bold, ), ).tr(), diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index 746917d3fb..d90ee40e47 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -84,6 +84,19 @@ class _DateTimePicker extends HookWidget { final date = useState(initialDateTime ?? DateTime.now()); final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation()); final timeZones = useMemoized(() => getAllTimeZones(), const []); + final menuEntries = timeZones + .map( + (timezone) => DropdownMenuEntry<_TimeZoneOffset>( + value: timezone, + label: timezone.display, + style: ButtonStyle( + textStyle: WidgetStatePropertyAll( + context.textTheme.bodyMedium, + ), + ), + ), + ) + .toList(); void pickDate() async { final now = DateTime.now(); @@ -120,93 +133,84 @@ class _DateTimePicker extends HookWidget { context.pop(dtWithOffset); } - return AlertDialog( - contentPadding: const EdgeInsets.all(30), - alignment: Alignment.center, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "edit_date_time_dialog_date_time", - textAlign: TextAlign.center, - ).tr(), - TextButton.icon( - onPressed: pickDate, - icon: Text( - DateFormat("dd-MM-yyyy hh:mm a").format(date.value), - style: context.textTheme.bodyLarge - ?.copyWith(color: context.primaryColor), - ), - label: const Icon( - Icons.edit_outlined, - size: 18, - ), + 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, + ), + ).tr(), ), - const Text( - "edit_date_time_dialog_timezone", - textAlign: TextAlign.center, - ).tr(), - DropdownMenu( - menuHeight: 300, - width: 280, - inputDecorationTheme: const InputDecorationTheme( - border: InputBorder.none, - contentPadding: EdgeInsets.zero, + 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, ), - trailingIcon: Padding( - padding: const EdgeInsets.only(right: 10), - child: Icon( + 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, ), - textStyle: context.textTheme.bodyLarge?.copyWith( - color: context.primaryColor, - ), - menuStyle: const MenuStyle( - fixedSize: WidgetStatePropertyAll(Size.fromWidth(350)), - alignment: Alignment(-1.25, 0.5), - ), - onSelected: (value) => tzOffset.value = value!, - initialSelection: tzOffset.value, - dropdownMenuEntries: timeZones - .map( - (t) => DropdownMenuEntry<_TimeZoneOffset>( - value: t, - label: t.display, - style: ButtonStyle( - textStyle: WidgetStatePropertyAll( - context.textTheme.bodyMedium, - ), - ), - ), - ) - .toList(), - ), - ], + ], + ), ), - 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(), - ), - ], ); } } diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index a3b3a19f34..1831a2d168 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); - final Widget? action; + final List? actions; + final bool showUploadButton; - const ImmichAppBar({super.key, this.action}); + const ImmichAppBar({super.key, this.actions, this.showUploadButton = true}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -58,15 +59,15 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { isLabelVisible: serverInfoState.isVersionMismatch || ((user?.isAdmin ?? false) && serverInfoState.isNewReleaseAvailable), - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: user == null ? const Icon( Icons.face_outlined, size: widgetSize, ) : UserCircleAvatar( - radius: 15, - size: 27, + radius: 17, + size: 31, user: user, ), ), @@ -111,7 +112,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { buildBackupIndicator() { final indicatorIcon = getBackupBadgeIcon(); - final badgeBackground = isDarkTheme ? Colors.blueGrey[800] : Colors.white; + final badgeBackground = context.colorScheme.surfaceContainer; return InkWell( onTap: () => context.pushRoute(const BackupControllerRoute()), @@ -123,7 +124,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { decoration: BoxDecoration( color: badgeBackground, border: Border.all( - color: isDarkTheme ? Colors.black : Colors.grey, + color: context.colorScheme.outline.withOpacity(.3), ), borderRadius: BorderRadius.circular(widgetSize / 2), ), @@ -132,7 +133,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, isLabelVisible: indicatorIcon != null, - offset: const Offset(2, 2), + offset: const Offset(-2, -12), child: Icon( Icons.backup_rounded, size: widgetSize, @@ -184,12 +185,18 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), actions: [ - if (action != null) - Padding(padding: const EdgeInsets.only(right: 20), child: action!), - Padding( - padding: const EdgeInsets.only(right: 20), - child: buildBackupIndicator(), - ), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), + if (showUploadButton) + Padding( + padding: const EdgeInsets.only(right: 20), + child: buildBackupIndicator(), + ), Padding( padding: const EdgeInsets.only(right: 20), child: buildProfileIndicator(), diff --git a/mobile/lib/widgets/common/immich_title_text.dart b/mobile/lib/widgets/common/immich_title_text.dart index 2a4edb4230..711d0bf396 100644 --- a/mobile/lib/widgets/common/immich_title_text.dart +++ b/mobile/lib/widgets/common/immich_title_text.dart @@ -21,6 +21,7 @@ class ImmichTitleText extends StatelessWidget { ), width: fontSize * 4, filterQuality: FilterQuality.high, + color: context.primaryColor, ); } } diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index e15623c86c..d33f6c4caf 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -51,9 +51,9 @@ class ImmichToast { padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(5.0), - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[50], + color: context.colorScheme.surfaceContainer, border: Border.all( - color: Colors.black12, + color: context.colorScheme.outline.withOpacity(.5), width: 1, ), ), diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index 0d1ac539dc..98ce66d2d1 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -51,7 +51,7 @@ class ChangePasswordForm extends HookConsumerWidget { ), style: TextStyle( fontSize: 14, - color: Colors.grey[700], + color: context.colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), @@ -191,9 +191,6 @@ class ChangePasswordButton extends ConsumerWidget { return ElevatedButton( style: ElevatedButton.styleFrom( visualDensity: VisualDensity.standard, - backgroundColor: context.primaryColor, - foregroundColor: Colors.grey[50], - elevation: 2, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), ), onPressed: onPressed, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 0395bdcb28..51383fe195 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; @@ -26,12 +28,15 @@ import 'package:immich_mobile/widgets/forms/login/login_button.dart'; import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; import 'package:immich_mobile/widgets/forms/login/password_input.dart'; import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; +import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; class LoginForm extends HookConsumerWidget { - const LoginForm({super.key}); + LoginForm({super.key}); + + final log = Logger('LoginForm'); @override Widget build(BuildContext context, WidgetRef ref) { @@ -55,7 +60,7 @@ class LoginForm extends HookConsumerWidget { )..repeat(); final serverInfo = ref.watch(serverInfoProvider); final warningMessage = useState(null); - + final loginFormKey = GlobalKey(); final ValueNotifier serverEndpoint = useState(null); checkVersionMismatch() async { @@ -175,12 +180,16 @@ class LoginForm extends HookConsumerWidget { } login() async { + TextInput.finishAutofillContext(); // Start loading isLoading.value = true; // This will remove current cache asset state of previous user login. ref.read(assetProvider.notifier).clearAllAsset(); + // Invalidate all api repository provider instance to take into account new access token + invalidateAllApiRepositoryProviders(ref); + try { final isAuthenticated = await ref.read(authenticationProvider.notifier).login( @@ -227,7 +236,9 @@ class LoginForm extends HookConsumerWidget { .getOAuthServerUrl(sanitizeUrl(serverEndpointController.text)); isLoading.value = true; - } catch (e) { + } catch (error, stack) { + log.severe('Error getting OAuth server Url: $error', stack); + ImmichToast.show( context: context, msg: "login_form_failed_get_oauth_server_config".tr(), @@ -239,10 +250,19 @@ class LoginForm extends HookConsumerWidget { } if (oAuthServerUrl != null) { - var loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl); + try { + final loginResponseDto = + await oAuthService.oAuthLogin(oAuthServerUrl); - if (loginResponseDto != null) { - var isSuccess = await ref + if (loginResponseDto == null) { + return; + } + + log.info( + "Finished OAuth login with response: ${loginResponseDto.userEmail}", + ); + + final isSuccess = await ref .watch(authenticationProvider.notifier) .setSuccessLoginInfo( accessToken: loginResponseDto.accessToken, @@ -256,17 +276,19 @@ class LoginForm extends HookConsumerWidget { ref.watch(backupProvider.notifier).resumeBackup(); } context.replaceRoute(const TabControllerRoute()); - } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); } - } + } catch (error, stack) { + log.severe('Error logging in with OAuth: $error', stack); - isLoading.value = false; + ImmichToast.show( + context: context, + msg: error.toString(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + } finally { + isLoading.value = false; + } } else { ImmichToast.show( context: context, @@ -478,7 +500,10 @@ class LoginForm extends HookConsumerWidget { // Note: This used to have an AnimatedSwitcher, but was removed // because of https://github.com/flutter/flutter/issues/120874 - serverSelectionOrLogin, + Form( + key: loginFormKey, + child: serverSelectionOrLogin, + ), ], ), ), diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart index f56942c69c..3b66a1cc35 100644 --- a/mobile/lib/widgets/map/map_theme_override.dart +++ b/mobile/lib/widgets/map/map_theme_override.dart @@ -70,6 +70,7 @@ class _MapThemeOverideState extends ConsumerState Widget build(BuildContext context) { _theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode)); + var appTheme = ref.watch(immichThemeProvider); useValueChanged(_theme, (_, __) { if (_theme == ThemeMode.system) { @@ -83,7 +84,9 @@ class _MapThemeOverideState extends ConsumerState }); return Theme( - data: _isDarkTheme ? immichDarkTheme : immichLightTheme, + data: _isDarkTheme + ? getThemeData(colorScheme: appTheme.dark) + : getThemeData(colorScheme: appTheme.light), child: widget.mapBuilder.call( ref.watch( mapStateNotifierProvider.select( diff --git a/mobile/lib/widgets/memories/memory_epilogue.dart b/mobile/lib/widgets/memories/memory_epilogue.dart index b817d67f05..9796bee6b1 100644 --- a/mobile/lib/widgets/memories/memory_epilogue.dart +++ b/mobile/lib/widgets/memories/memory_epilogue.dart @@ -1,6 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; class MemoryEpilogue extends StatefulWidget { @@ -49,24 +48,26 @@ class _MemoryEpilogueState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( + Icon( Icons.check_circle_outline_sharp, - color: immichDarkThemePrimaryColor, + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, size: 64.0, ), const SizedBox(height: 16.0), Text( "memories_all_caught_up", - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), ).tr(), const SizedBox(height: 16.0), Text( "memories_check_back_tomorrow", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), ).tr(), const SizedBox(height: 16.0), TextButton( @@ -74,7 +75,9 @@ class _MemoryEpilogueState extends State child: Text( "memories_start_over", style: context.textTheme.displayMedium?.copyWith( - color: immichDarkThemePrimaryColor, + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, ), ).tr(), ), @@ -108,9 +111,9 @@ class _MemoryEpilogueState extends State ), Text( "memories_swipe_to_close", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.white, - ), + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), ).tr(), ], ), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 4d4fa8c4e0..41e9cc628e 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -1,6 +1,8 @@ import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; 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/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -9,6 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_image.dart'; class MemoryLane extends HookConsumerWidget { const MemoryLane({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final memoryLaneFutureProvider = ref.watch(memoryFutureProvider); @@ -16,82 +19,35 @@ class MemoryLane extends HookConsumerWidget { final memoryLane = memoryLaneFutureProvider .whenData( (memories) => memories != null - ? SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: memories.length, - padding: const EdgeInsets.only( - right: 8.0, - bottom: 8, - top: 10, - left: 10, + ? ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + ), + child: CarouselView( + itemExtent: 145.0, + shrinkExtent: 1.0, + elevation: 2, + backgroundColor: Colors.black, + overlayColor: WidgetStateProperty.all( + Colors.white.withOpacity(0.1), ), - itemBuilder: (context, index) { - final memory = memories[index]; - - return GestureDetector( - onTap: () { - ref - .read(hapticFeedbackProvider.notifier) - .heavyImpact(); - context.pushRoute( - MemoryRoute( - memories: memories, - memoryIndex: index, - ), - ); - }, - child: Stack( - children: [ - Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(13.0), - ), - clipBehavior: Clip.hardEdge, - child: ColorFiltered( - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.2), - BlendMode.darken, - ), - child: Hero( - tag: 'memory-${memory.assets[0].id}', - child: ImmichImage( - memory.assets[0], - fit: BoxFit.cover, - width: 130, - height: 200, - placeholder: const ThumbnailPlaceholder( - width: 130, - height: 200, - ), - ), - ), - ), - ), - Positioned( - bottom: 16, - left: 16, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 114, - ), - child: Text( - memory.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - color: Colors.white, - fontSize: 15, - ), - ), - ), - ), - ], + onTap: (memoryIndex) { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + context.pushRoute( + MemoryRoute( + memories: memories, + memoryIndex: memoryIndex, ), ); }, + children: memories + .mapIndexed( + (index, memory) => MemoryCard( + index: index, + memory: memory, + ), + ) + .toList(), ), ) : const SizedBox(), @@ -101,3 +57,60 @@ class MemoryLane extends HookConsumerWidget { return memoryLane ?? const SizedBox(); } } + +class MemoryCard extends ConsumerWidget { + const MemoryCard({ + super.key, + required this.index, + required this.memory, + }); + + final int index; + final Memory memory; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Stack( + children: [ + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withOpacity(0.2), + BlendMode.darken, + ), + child: Hero( + tag: 'memory-${memory.assets[0].id}', + child: ImmichImage( + memory.assets[0], + fit: BoxFit.cover, + width: 205, + height: 200, + placeholder: const ThumbnailPlaceholder( + width: 105, + height: 200, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 114, + ), + child: Text( + memory.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/memories/memory_progress_indicator.dart b/mobile/lib/widgets/memories/memory_progress_indicator.dart index 0ee3893cb9..438816d99c 100644 --- a/mobile/lib/widgets/memories/memory_progress_indicator.dart +++ b/mobile/lib/widgets/memories/memory_progress_indicator.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; class MemoryProgressIndicator extends StatelessWidget { /// The number of ticks in the progress indicator @@ -25,8 +25,11 @@ class MemoryProgressIndicator extends StatelessWidget { children: [ LinearProgressIndicator( value: value, - backgroundColor: Colors.grey[600], - color: immichDarkThemePrimaryColor, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + backgroundColor: Colors.grey[800], + color: context.isDarkTheme + ? context.colorScheme.primary + : context.colorScheme.inversePrimary, ), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/mobile/lib/widgets/partner/partner_list.dart b/mobile/lib/widgets/partner/partner_list.dart deleted file mode 100644 index 53a27c48ab..0000000000 --- a/mobile/lib/widgets/partner/partner_list.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/widgets/common/user_avatar.dart'; - -class PartnerList extends HookConsumerWidget { - const PartnerList({super.key, required this.partner}); - - final List partner; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return SliverList( - delegate: - SliverChildBuilderDelegate(listEntry, childCount: partner.length), - ); - } - - Widget listEntry(BuildContext context, int index) { - final User p = partner[index]; - return ListTile( - contentPadding: const EdgeInsets.only( - left: 12.0, - right: 18.0, - ), - leading: userAvatar(context, p, radius: 24), - title: Text( - "partner_list_user_photos", - style: context.textTheme.labelLarge, - ).tr( - namedArgs: { - 'user': p.name, - }, - ), - trailing: Text( - "partner_list_view_all", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - onTap: () => context.pushRoute((PartnerDetailRoute(partner: p))), - ); - } -} diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 55be81a5b3..7f72750afe 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -1,5 +1,3 @@ -library photo_view; - import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index 9594912078..b8918309bc 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -1,5 +1,3 @@ -library photo_view_gallery; - import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart' diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index 8e90cc8504..cd937a6a42 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -59,7 +59,7 @@ class ExploreGrid extends StatelessWidget { ), ) : context.pushRoute( - SearchInputRoute( + SearchRoute( prefilter: SearchFilter( people: {}, location: SearchLocationFilter( diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index 2e5618c9e0..e2110c9c29 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -51,10 +51,14 @@ class CameraPicker extends HookConsumerWidget { controller: makeTextController, leadingIcon: const Icon(Icons.photo_camera_rounded), onSelected: (value) { + if (value.toString() == selectedMake.value) { + return; + } selectedMake.value = value.toString(); + modelTextController.value = TextEditingValue.empty; onSelect({ 'make': selectedMake.value, - 'model': selectedModel.value, + 'model': null, }); }, ); diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart index 55b54ce46a..dd8785459f 100644 --- a/mobile/lib/widgets/search/search_filter/common/dropdown.dart +++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart @@ -18,13 +18,6 @@ class SearchDropdown extends StatelessWidget { @override Widget build(BuildContext context) { - final inputDecorationTheme = InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - ), - contentPadding: const EdgeInsets.only(left: 16), - ); - final menuStyle = MenuStyle( shape: WidgetStatePropertyAll( RoundedRectangleBorder( @@ -36,11 +29,11 @@ class SearchDropdown extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { return DropdownMenu( + controller: controller, leadingIcon: leadingIcon, width: constraints.maxWidth, dropdownMenuEntries: dropdownMenuEntries, label: label, - inputDecorationTheme: inputDecorationTheme, menuStyle: menuStyle, trailingIcon: const Icon(Icons.arrow_drop_down_rounded), selectedTrailingIcon: const Icon(Icons.arrow_drop_up_rounded), diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index d79ae5bd95..dfc435c807 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -3,23 +3,23 @@ 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/interfaces/person_api.interface.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'; -import 'package:openapi/api.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { var imageSize = 45.0; final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return people.widgetWhen( onData: (people) { 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 b2e0d086ac..2a445c8ad7 100644 --- a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart +++ b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart @@ -22,9 +22,9 @@ class SearchFilterChip extends StatelessWidget { onTap: onTap, child: Card( elevation: 0, - color: context.primaryColor.withAlpha(25), + color: context.primaryColor.withOpacity(.5), shape: StadiumBorder( - side: BorderSide(color: context.primaryColor), + side: BorderSide(color: context.colorScheme.secondaryContainer), ), child: Padding( padding: @@ -47,8 +47,9 @@ class SearchFilterChip extends StatelessWidget { onTap: onTap, child: Card( elevation: 0, - shape: - StadiumBorder(side: BorderSide(color: Colors.grey.withAlpha(100))), + shape: StadiumBorder( + side: BorderSide(color: context.colorScheme.outline.withAlpha(15)), + ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0), child: Row( diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart index 20747913fb..b4a12ab826 100644 --- a/mobile/lib/widgets/search/search_map_thumbnail.dart +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -13,6 +13,7 @@ class SearchMapThumbnail extends StatelessWidget { }); final double size; + final bool showTitle = true; @override Widget build(BuildContext context) { diff --git a/mobile/lib/widgets/search/thumbnail_with_info_container.dart b/mobile/lib/widgets/search/thumbnail_with_info_container.dart index 6df45ec464..d2084bdcc8 100644 --- a/mobile/lib/widgets/search/thumbnail_with_info_container.dart +++ b/mobile/lib/widgets/search/thumbnail_with_info_container.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class ThumbnailWithInfoContainer extends StatelessWidget { const ThumbnailWithInfoContainer({ @@ -25,7 +26,14 @@ class ThumbnailWithInfoContainer extends StatelessWidget { Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), - color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[100], + gradient: LinearGradient( + colors: [ + context.colorScheme.surfaceContainer, + context.colorScheme.surfaceContainer.darken(amount: .1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), ), foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), @@ -34,7 +42,7 @@ class ThumbnailWithInfoContainer extends StatelessWidget { begin: FractionalOffset.topCenter, end: FractionalOffset.bottomCenter, colors: [ - Colors.grey.withOpacity(0.0), + Colors.transparent, label == '' ? Colors.black.withOpacity(0.1) : Colors.black.withOpacity(0.5), diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 25bcf2d06e..2cecba6c4b 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -1,9 +1,12 @@ import 'dart:io'; +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/providers/backup/backup_verification.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; @@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget { useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets); final isAdvancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums); final isCorruptCheckInProgress = ref.watch(backupVerificationProvider); + final isAlbumSyncInProgress = useState(false); + + syncAlbums() async { + isAlbumSyncInProgress.value = true; + try { + await ref.read(assetServiceProvider).syncUploadedAssetToAlbums(); + } catch (_) { + } finally { + Future.delayed(const Duration(seconds: 1), () { + isAlbumSyncInProgress.value = false; + }); + } + } final backupSettings = [ const ForegroundBackupSettings(), @@ -31,9 +48,8 @@ class BackupSettings extends HookConsumerWidget { if (Platform.isIOS) SettingsSwitchListTile( valueNotifier: ignoreIcloudAssets, - title: 'Ignore iCloud photos', - subtitle: - 'Photos that are stored on iCloud will not be uploaded to the Immich server', + title: 'ignore_icloud_photos'.tr(), + subtitle: 'ignore_icloud_photos_description'.tr(), ), if (Platform.isAndroid && isAdvancedTroubleshooting.value) SettingsButtonListTile( @@ -58,6 +74,23 @@ class BackupSettings extends HookConsumerWidget { .performBackupCheck(context) : null, ), + if (albumSync.value) + SettingsButtonListTile( + icon: Icons.photo_album_outlined, + title: 'sync_albums'.tr(), + subtitle: Text( + "sync_albums_manual_subtitle".tr(), + ), + buttonText: 'sync_albums'.tr(), + child: isAlbumSyncInProgress.value + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : ElevatedButton( + onPressed: syncAlbums, + child: Text('sync'.tr()), + ), + ), ]; return SettingsSubPageScaffold( diff --git a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart index 12efa52b2d..2e1f165602 100644 --- a/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart +++ b/mobile/lib/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; class CustomeProxyHeaderSettings extends StatelessWidget { @@ -20,8 +21,8 @@ class CustomeProxyHeaderSettings extends StatelessWidget { ), subtitle: Text( "headers_settings_tile_subtitle".tr(), - style: const TextStyle( - fontSize: 14, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, ), ), onTap: () => context.pushRoute(const HeaderSettingsRoute()), diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 378d32085e..990dcfdfe8 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -40,9 +40,7 @@ class LanguageSettings extends HookConsumerWidget { ), ), backgroundColor: WidgetStatePropertyAll( - context.isDarkTheme - ? Colors.grey[900]! - : context.scaffoldBackgroundColor, + context.colorScheme.surfaceContainer, ), ), menuHeight: context.height * 0.5, diff --git a/mobile/lib/widgets/settings/local_storage_settings.dart b/mobile/lib/widgets/settings/local_storage_settings.dart index 6e7723cbff..5b21d9bd4d 100644 --- a/mobile/lib/widgets/settings/local_storage_settings.dart +++ b/mobile/lib/widgets/settings/local_storage_settings.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/db.provider.dart'; class LocalStorageSettings extends HookConsumerWidget { @@ -35,10 +36,10 @@ class LocalStorageSettings extends HookConsumerWidget { fontWeight: FontWeight.w500, ), ).tr(args: ["${cacheItemCount.value}"]), - subtitle: const Text( + subtitle: Text( "cache_settings_duplicated_assets_subtitle", - style: TextStyle( - fontSize: 14, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, ), ).tr(), trailing: TextButton( diff --git a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart index 62508df6e2..8a3684e093 100644 --- a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart @@ -15,6 +15,9 @@ class PreferenceSetting extends StatelessWidget { HapticSetting(), ]; - return const SettingsSubPageScaffold(settings: preferenceSettings); + return const SettingsSubPageScaffold( + settings: preferenceSettings, + showDivider: true, + ); } } diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart new file mode 100644 index 0000000000..1c7cd1f207 --- /dev/null +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -0,0 +1,221 @@ +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/immich_colors.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/immich_app_theme.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; + +class PrimaryColorSetting extends HookConsumerWidget { + const PrimaryColorSetting({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeProvider = ref.read(immichThemeProvider); + + final primaryColorSetting = + useAppSettingsState(AppSettingsEnum.primaryColor); + final systemPrimaryColorSetting = + useAppSettingsState(AppSettingsEnum.dynamicTheme); + + final currentPreset = useValueNotifier(ref.read(immichThemePresetProvider)); + const tileSize = 55.0; + + useValueChanged( + primaryColorSetting.value, + (_, __) => currentPreset.value = ImmichColorPreset.values + .firstWhere((e) => e.name == primaryColorSetting.value), + ); + + void popBottomSheet() { + Future.delayed(const Duration(milliseconds: 200), () { + Navigator.pop(context); + }); + } + + onUseSystemColorChange(bool newValue) { + systemPrimaryColorSetting.value = newValue; + ref.watch(dynamicThemeSettingProvider.notifier).state = newValue; + ref.invalidate(immichThemeProvider); + popBottomSheet(); + } + + onPrimaryColorChange(ImmichColorPreset colorPreset) { + primaryColorSetting.value = colorPreset.name; + ref.watch(immichThemePresetProvider.notifier).state = colorPreset; + ref.invalidate(immichThemeProvider); + + //turn off system color setting + if (systemPrimaryColorSetting.value) { + onUseSystemColorChange(false); + } else { + popBottomSheet(); + } + } + + buildPrimaryColorTile({ + required Color topColor, + required Color bottomColor, + required double tileSize, + required bool showSelector, + }) { + return Container( + margin: const EdgeInsets.all(4.0), + child: Stack( + children: [ + Container( + height: tileSize, + width: tileSize, + decoration: BoxDecoration( + color: bottomColor, + borderRadius: const BorderRadius.all(Radius.circular(100)), + ), + ), + Container( + height: tileSize / 2, + width: tileSize, + decoration: BoxDecoration( + color: topColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(100), + topRight: Radius.circular(100), + ), + ), + ), + if (showSelector) + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(100)), + color: Colors.grey[900]?.withOpacity(.4), + ), + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 25, + ), + ), + ), + ), + ], + ), + ); + } + + bottomSheetContent() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.center, + child: Text( + "theme_setting_primary_color_title".tr(), + style: context.textTheme.titleLarge, + ), + ), + if (isDynamicThemeAvailable) + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + margin: const EdgeInsets.only(top: 10), + child: SwitchListTile.adaptive( + contentPadding: + const EdgeInsets.symmetric(vertical: 6, horizontal: 20), + dense: true, + activeColor: context.primaryColor, + tileColor: context.colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + title: Text( + 'theme_setting_system_primary_color_title'.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + height: 1.5, + ), + ), + value: systemPrimaryColorSetting.value, + onChanged: onUseSystemColorChange, + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: ImmichColorPreset.values.map((themePreset) { + var theme = themePreset.getTheme(); + + return GestureDetector( + onTap: () => onPrimaryColorChange(themePreset), + child: buildPrimaryColorTile( + topColor: theme.light.primary, + bottomColor: theme.dark.primary, + tileSize: tileSize, + showSelector: currentPreset.value == themePreset && + !systemPrimaryColorSetting.value, + ), + ); + }).toList(), + ), + ), + ], + ); + } + + return ListTile( + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext ctx) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 0), + child: bottomSheetContent(), + ); + }, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + title: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "theme_setting_primary_color_title".tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + "theme_setting_primary_color_subtitle".tr(), + style: context.textTheme.bodyMedium + ?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 8.0), + child: buildPrimaryColorTile( + topColor: themeProvider.light.primary, + bottomColor: themeProvider.dark.primary, + tileSize: 42.0, + showSelector: false, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index 5780054428..050593a229 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/settings/preference_settings/primary_color_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -16,11 +17,16 @@ class ThemeSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode); - final currentTheme = useValueNotifier(ref.read(immichThemeProvider)); + final currentTheme = useValueNotifier(ref.read(immichThemeModeProvider)); final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark); final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system); + final applyThemeToBackgroundSetting = + useAppSettingsState(AppSettingsEnum.colorfulInterface); + final applyThemeToBackgroundProvider = + useValueNotifier(ref.read(colorfulInterfaceSettingProvider)); + useValueChanged( currentThemeString.value, (_, __) => currentTheme.value = switch (currentThemeString.value) { @@ -30,12 +36,18 @@ class ThemeSetting extends HookConsumerWidget { }, ); + useValueChanged( + applyThemeToBackgroundSetting.value, + (_, __) => applyThemeToBackgroundProvider.value = + applyThemeToBackgroundSetting.value, + ); + void onThemeChange(bool isDark) { if (isDark) { - ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; currentThemeString.value = "dark"; } else { - ref.watch(immichThemeProvider.notifier).state = ThemeMode.light; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light; currentThemeString.value = "light"; } } @@ -44,7 +56,7 @@ class ThemeSetting extends HookConsumerWidget { if (isSystem) { currentThemeString.value = "system"; isSystemTheme.value = true; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.system; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.system; } else { final currentSystemBrightness = MediaQuery.platformBrightnessOf(context); @@ -52,14 +64,20 @@ class ThemeSetting extends HookConsumerWidget { isDarkTheme.value = currentSystemBrightness == Brightness.dark; if (currentSystemBrightness == Brightness.light) { currentThemeString.value = "light"; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.light; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.light; } else if (currentSystemBrightness == Brightness.dark) { currentThemeString.value = "dark"; - ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark; + ref.watch(immichThemeModeProvider.notifier).state = ThemeMode.dark; } } } + void onSurfaceColorSettingChange(bool useColorfulInterface) { + applyThemeToBackgroundSetting.value = useColorfulInterface; + ref.watch(colorfulInterfaceSettingProvider.notifier).state = + useColorfulInterface; + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -75,6 +93,13 @@ class ThemeSetting extends HookConsumerWidget { title: 'theme_setting_dark_mode_switch'.tr(), onChanged: onThemeChange, ), + const PrimaryColorSetting(), + SettingsSwitchListTile( + valueNotifier: applyThemeToBackgroundProvider, + title: "theme_setting_colorful_interface_title".tr(), + subtitle: 'theme_setting_colorful_interface_subtitle'.tr(), + onChanged: onSurfaceColorSettingChange, + ), ], ); } diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index fca5b878de..c8bd8e4b58 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class SettingsButtonListTile extends StatelessWidget { final IconData icon; @@ -8,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget { final Widget? subtitle; final String? subtileText; final String buttonText; + final Widget? child; final void Function()? onButtonTap; const SettingsButtonListTile({ @@ -17,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget { this.subtileText, this.subtitle, required this.buttonText, + this.child, this.onButtonTap, super.key, }); @@ -39,10 +42,16 @@ class SettingsButtonListTile extends StatelessWidget { children: [ if (subtileText != null) const SizedBox(height: 4), if (subtileText != null) - Text(subtileText!, style: context.textTheme.bodyMedium), + Text( + subtileText!, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), if (subtitle != null) subtitle!, const SizedBox(height: 6), - ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), + child ?? + ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], ), ); diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart index c7328f0b96..8aa4ec0a60 100644 --- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; class SettingsSwitchListTile extends StatelessWidget { final ValueNotifier valueNotifier; @@ -8,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget { final String? subtitle; final IconData? icon; final Function(bool)? onChanged; + final EdgeInsets? contentPadding; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; const SettingsSwitchListTile({ required this.valueNotifier, @@ -16,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget { this.icon, this.enabled = true, this.onChanged, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 20), + this.titleStyle, + this.subtitleStyle, super.key, }); @@ -29,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget { } return SwitchListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 20), + contentPadding: contentPadding, selectedTileColor: enabled ? null : context.themeData.disabledColor, value: valueNotifier.value, onChanged: onSwitchChanged, @@ -44,18 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget { : null, title: Text( title, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - color: enabled ? null : context.themeData.disabledColor, - height: 1.5, - ), + style: titleStyle ?? + context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: enabled ? null : context.themeData.disabledColor, + height: 1.5, + ), ), subtitle: subtitle != null ? Text( subtitle!, - style: context.textTheme.bodyMedium?.copyWith( - color: enabled ? null : context.themeData.disabledColor, - ), + style: subtitleStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: enabled + ? context.colorScheme.onSurfaceSecondary + : context.themeData.disabledColor, + ), ) : null, ); diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index 0daddd6d88..21d9738b84 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/utils/http_ssl_cert_override.dart'; class SslClientCertSettings extends StatefulWidget { @@ -40,7 +41,9 @@ class _SslClientCertSettingsState extends State { children: [ Text( "client_cert_subtitle".tr(), - style: context.textTheme.bodyMedium, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), ), const SizedBox( height: 6, diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index 86c0890cd2..9e29f5f9a0 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -65,8 +65,8 @@ class SharedLinkItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeData = context.themeData; - final isDarkMode = themeData.brightness == Brightness.dark; + final colorScheme = context.colorScheme; + final isDarkMode = colorScheme.brightness == Brightness.dark; final thumbnailUrl = sharedLink.thumbAssetId != null ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) : null; @@ -159,7 +159,7 @@ class SharedLinkItem extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(right: 10), child: Chip( - backgroundColor: themeData.primaryColor, + backgroundColor: colorScheme.primary, label: Text( labelText, style: TextStyle( @@ -240,7 +240,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: themeData.primaryColor.withOpacity(0.9), + color: colorScheme.primary.withOpacity(0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( @@ -253,7 +253,7 @@ class SharedLinkItem extends ConsumerWidget { child: Text( sharedLink.title, style: TextStyle( - color: themeData.primaryColor, + color: colorScheme.primary, fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis, ), @@ -268,7 +268,7 @@ class SharedLinkItem extends ConsumerWidget { child: Tooltip( verticalOffset: 0, decoration: BoxDecoration( - color: themeData.primaryColor.withOpacity(0.9), + color: colorScheme.primary.withOpacity(0.9), borderRadius: BorderRadius.circular(10), ), textStyle: TextStyle( diff --git a/mobile/openapi/.gitignore b/mobile/openapi/.gitignore index 1be28ced09..0f74d293b9 100644 --- a/mobile/openapi/.gitignore +++ b/mobile/openapi/.gitignore @@ -3,7 +3,9 @@ .dart_tool/ .packages build/ -pubspec.lock # Except for application packages + +# Except for application packages +pubspec.lock doc/api/ diff --git a/mobile/openapi/.openapi-generator/VERSION b/mobile/openapi/.openapi-generator/VERSION index 18bb4182dd..09a6d30847 100644 --- a/mobile/openapi/.openapi-generator/VERSION +++ b/mobile/openapi/.openapi-generator/VERSION @@ -1 +1 @@ -7.5.0 +7.8.0 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 52e2e3cb40..4cdb08ce99 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,8 +3,8 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.111.0 -- Generator version: 7.5.0 +- API version: 1.119.1 +- Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements @@ -86,8 +86,8 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | -*AlbumsApi* | [**getAlbumCount**](doc//AlbumsApi.md#getalbumcount) | **GET** /albums/count | *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | +*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | @@ -107,7 +107,6 @@ Class | Method | HTTP request | Description *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* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent | *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 | @@ -116,15 +115,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeprecatedApi* | [**getAboutInfo**](doc//DeprecatedApi.md#getaboutinfo) | **GET** /server-info/about | -*DeprecatedApi* | [**getServerConfig**](doc//DeprecatedApi.md#getserverconfig) | **GET** /server-info/config | -*DeprecatedApi* | [**getServerFeatures**](doc//DeprecatedApi.md#getserverfeatures) | **GET** /server-info/features | -*DeprecatedApi* | [**getServerStatistics**](doc//DeprecatedApi.md#getserverstatistics) | **GET** /server-info/statistics | -*DeprecatedApi* | [**getServerVersion**](doc//DeprecatedApi.md#getserverversion) | **GET** /server-info/version | -*DeprecatedApi* | [**getStorage**](doc//DeprecatedApi.md#getstorage) | **GET** /server-info/storage | -*DeprecatedApi* | [**getSupportedMediaTypes**](doc//DeprecatedApi.md#getsupportedmediatypes) | **GET** /server-info/media-types | -*DeprecatedApi* | [**getTheme**](doc//DeprecatedApi.md#gettheme) | **GET** /server-info/theme | -*DeprecatedApi* | [**pingServer**](doc//DeprecatedApi.md#pingserver) | **GET** /server-info/ping | +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *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 | @@ -133,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -140,12 +132,10 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | *LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | @@ -167,7 +157,6 @@ Class | Method | HTTP request | Description *PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people | *PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people | *PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} | -*PeopleApi* | [**getPersonAssets**](doc//PeopleApi.md#getpersonassets) | **GET** /people/{id}/assets | *PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | *PeopleApi* | [**getPersonThumbnail**](doc//PeopleApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | *PeopleApi* | [**mergePerson**](doc//PeopleApi.md#mergeperson) | **POST** /people/{id}/merge | @@ -180,19 +169,21 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | +*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | +*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config | +*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features | *ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | +*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics | +*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version | +*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | +*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | +*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | +*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | +*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | -*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about | -*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | -*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | -*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics | -*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | -*ServerInfoApi* | [**getStorage**](doc//ServerInfoApi.md#getstorage) | **GET** /server-info/storage | -*ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types | -*ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme | -*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | @@ -204,6 +195,12 @@ Class | Method | HTTP request | Description *SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | *SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | *SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | +*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | +*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | +*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks | +*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* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | @@ -213,14 +210,15 @@ Class | Method | HTTP request | Description *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | +*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | *TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | *TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | *TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | -*TagsApi* | [**getTagAssets**](doc//TagsApi.md#gettagassets) | **GET** /tags/{id}/assets | *TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | *TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | *TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | -*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PATCH** /tags/{id} | +*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | +*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | *TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | *TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | @@ -246,6 +244,8 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | +*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder | +*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | ## Documentation For Models @@ -259,8 +259,8 @@ Class | Method | HTTP request | Description - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) - - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) + - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md) - [AlbumUserAddDto](doc//AlbumUserAddDto.md) - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) @@ -288,6 +288,7 @@ Class | Method | HTTP request | Description - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) + - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) @@ -305,7 +306,7 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - - [CreateTagDto](doc//CreateTagDto.md) + - [DatabaseBackupConfig](doc//DatabaseBackupConfig.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -324,10 +325,13 @@ Class | Method | HTTP request | Description - [FileReportDto](doc//FileReportDto.md) - [FileReportFixDto](doc//FileReportFixDto.md) - [FileReportItemDto](doc//FileReportItemDto.md) + - [FoldersResponse](doc//FoldersResponse.md) + - [FoldersUpdate](doc//FoldersUpdate.md) - [ImageFormat](doc//ImageFormat.md) - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -339,15 +343,15 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - - [MapTheme](doc//MapTheme.md) + - [MemoriesResponse](doc//MemoriesResponse.md) + - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) - - [MemoryResponse](doc//MemoryResponse.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemoryType](doc//MemoryType.md) - - [MemoryUpdate](doc//MemoryUpdate.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) @@ -359,9 +363,12 @@ Class | Method | HTTP request | Description - [PartnerResponseDto](doc//PartnerResponseDto.md) - [PathEntityType](doc//PathEntityType.md) - [PathType](doc//PathType.md) + - [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponseDto](doc//PeopleResponseDto.md) + - [PeopleUpdate](doc//PeopleUpdate.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [Permission](doc//Permission.md) - [PersonCreateDto](doc//PersonCreateDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) @@ -371,10 +378,12 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RandomSearchDto](doc//RandomSearchDto.md) + - [RatingsResponse](doc//RatingsResponse.md) + - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) @@ -391,6 +400,7 @@ Class | Method | HTTP request | Description - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md) - [ServerThemeDto](doc//ServerThemeDto.md) + - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) @@ -400,8 +410,15 @@ Class | Method | HTTP request | Description - [SignUpDto](doc//SignUpDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) + - [SourceType](doc//SourceType.md) + - [StackCreateDto](doc//StackCreateDto.md) + - [StackResponseDto](doc//StackResponseDto.md) + - [StackUpdateDto](doc//StackUpdateDto.md) + - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) + - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) @@ -410,6 +427,7 @@ Class | Method | HTTP request | Description - [SystemConfigLoggingDto](doc//SystemConfigLoggingDto.md) - [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md) - [SystemConfigMapDto](doc//SystemConfigMapDto.md) + - [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md) - [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md) - [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md) - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md) @@ -423,20 +441,26 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) + - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md) + - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md) + - [TagCreateDto](doc//TagCreateDto.md) - [TagResponseDto](doc//TagResponseDto.md) - - [TagTypeEnum](doc//TagTypeEnum.md) + - [TagUpdateDto](doc//TagUpdateDto.md) + - [TagUpsertDto](doc//TagUpsertDto.md) + - [TagsResponse](doc//TagsResponse.md) + - [TagsUpdate](doc//TagsUpdate.md) + - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) + - [TrashResponseDto](doc//TrashResponseDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - - [UpdateStackParentDto](doc//UpdateStackParentDto.md) - - [UpdateTagDto](doc//UpdateTagDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7aaf38de7..b4c51c8e99 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; import 'package:http/http.dart'; import 'package:intl/intl.dart'; import 'package:meta/meta.dart'; @@ -50,9 +51,9 @@ part 'api/partners_api.dart'; part 'api/people_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; -part 'api/server_info_api.dart'; part 'api/sessions_api.dart'; part 'api/shared_links_api.dart'; +part 'api/stacks_api.dart'; part 'api/sync_api.dart'; part 'api/system_config_api.dart'; part 'api/system_metadata_api.dart'; @@ -61,6 +62,7 @@ part 'api/timeline_api.dart'; part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; +part 'api/view_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; @@ -71,8 +73,8 @@ part 'model/activity_response_dto.dart'; part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_onboarding_update_dto.dart'; -part 'model/album_count_response_dto.dart'; part 'model/album_response_dto.dart'; +part 'model/album_statistics_response_dto.dart'; part 'model/album_user_add_dto.dart'; part 'model/album_user_create_dto.dart'; part 'model/album_user_response_dto.dart'; @@ -100,6 +102,7 @@ part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; +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'; @@ -117,7 +120,7 @@ part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; -part 'model/create_tag_dto.dart'; +part 'model/database_backup_config.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -136,10 +139,13 @@ part 'model/file_checksum_response_dto.dart'; part 'model/file_report_dto.dart'; part 'model/file_report_fix_dto.dart'; part 'model/file_report_item_dto.dart'; +part 'model/folders_response.dart'; +part 'model/folders_update.dart'; part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -151,15 +157,15 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; -part 'model/map_theme.dart'; +part 'model/memories_response.dart'; +part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_lane_response_dto.dart'; -part 'model/memory_response.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_type.dart'; -part 'model/memory_update.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; @@ -171,9 +177,12 @@ part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; part 'model/path_entity_type.dart'; part 'model/path_type.dart'; +part 'model/people_response.dart'; part 'model/people_response_dto.dart'; +part 'model/people_update.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/permission.dart'; part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; @@ -183,10 +192,12 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/random_search_dto.dart'; +part 'model/ratings_response.dart'; +part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; -part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; @@ -203,6 +214,7 @@ part 'model/server_ping_response.dart'; part 'model/server_stats_response_dto.dart'; part 'model/server_storage_response_dto.dart'; part 'model/server_theme_dto.dart'; +part 'model/server_version_history_response_dto.dart'; part 'model/server_version_response_dto.dart'; part 'model/session_response_dto.dart'; part 'model/shared_link_create_dto.dart'; @@ -212,8 +224,15 @@ part 'model/shared_link_type.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_info_response_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/system_config_backups_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; +part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; @@ -222,6 +241,7 @@ part 'model/system_config_library_watch_dto.dart'; part 'model/system_config_logging_dto.dart'; part 'model/system_config_machine_learning_dto.dart'; part 'model/system_config_map_dto.dart'; +part 'model/system_config_metadata_dto.dart'; part 'model/system_config_new_version_check_dto.dart'; part 'model/system_config_notifications_dto.dart'; part 'model/system_config_o_auth_dto.dart'; @@ -235,20 +255,26 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; +part 'model/tag_bulk_assets_dto.dart'; +part 'model/tag_bulk_assets_response_dto.dart'; +part 'model/tag_create_dto.dart'; part 'model/tag_response_dto.dart'; -part 'model/tag_type_enum.dart'; +part 'model/tag_update_dto.dart'; +part 'model/tag_upsert_dto.dart'; +part 'model/tags_response.dart'; +part 'model/tags_update.dart'; +part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; +part 'model/trash_response_dto.dart'; part 'model/update_album_dto.dart'; part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; -part 'model/update_stack_parent_dto.dart'; -part 'model/update_tag_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; part 'model/user_admin_delete_dto.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index fb81c04616..eb2bb7c0bd 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -218,47 +218,6 @@ class AlbumsApi { } } - /// Performs an HTTP 'GET /albums/count' operation and returns the [Response]. - Future getAlbumCountWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/albums/count'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future getAlbumCount() async { - final response = await getAlbumCountWithHttpInfo(); - 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), 'AlbumCountResponseDto',) as AlbumCountResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response]. /// Parameters: /// @@ -322,6 +281,47 @@ class AlbumsApi { return null; } + /// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response]. + Future getAlbumStatisticsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/albums/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAlbumStatistics() async { + final response = await getAlbumStatisticsWithHttpInfo(); + 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), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /albums' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index d7d386130b..fd89986980 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -449,7 +449,10 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [num] count: @@ -482,6 +485,8 @@ class AssetsApi { ); } + /// This property was deprecated in v1.116.0 + /// /// Parameters: /// /// * [num] count: @@ -804,45 +809,6 @@ class AssetsApi { } } - /// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response]. - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { - // ignore: prefer_const_declarations - final path = r'/assets/stack/parent'; - - // ignore: prefer_final_locals - Object? postBody = updateStackParentDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'PUT', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [UpdateStackParentDto] updateStackParentDto (required): - Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async { - final response = await updateStackParentWithHttpInfo(updateStackParentDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - /// Performs an HTTP 'POST /assets' operation and returns the [Response]. /// Parameters: /// @@ -867,14 +833,12 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [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? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + 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'; @@ -930,10 +894,6 @@ class AssetsApi { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isOffline != null) { - hasFields = true; - mp.fields[r'isOffline'] = parameterToString(isOffline); - } if (isVisible != null) { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); @@ -985,15 +945,13 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + Future uploadAsset(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 { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 18518cca69..30e35b451c 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -16,12 +16,16 @@ class DeprecatedApi { final ApiClient apiClient; - /// This property was deprecated in v1.107.0 + /// This property was deprecated in v1.116.0 /// /// Note: This method returns the HTTP [Response]. - Future getAboutInfoWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [num] count: + Future getRandomWithHttpInfo({ num? count, }) async { // ignore: prefer_const_declarations - final path = r'/server-info/about'; + final path = r'/assets/random'; // ignore: prefer_final_locals Object? postBody; @@ -30,6 +34,10 @@ class DeprecatedApi { final headerParams = {}; final formParams = {}; + if (count != null) { + queryParams.addAll(_queryParams('', 'count', count)); + } + const contentTypes = []; @@ -44,97 +52,13 @@ class DeprecatedApi { ); } - /// This property was deprecated in v1.107.0 - Future getAboutInfo() async { - final response = await getAboutInfoWithHttpInfo(); - 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), 'ServerAboutResponseDto',) as ServerAboutResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 + /// This property was deprecated in v1.116.0 /// - /// Note: This method returns the HTTP [Response]. - Future getServerConfigWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/config'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerConfig() async { - final response = await getServerConfigWithHttpInfo(); - 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), 'ServerConfigDto',) as ServerConfigDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 + /// Parameters: /// - /// Note: This method returns the HTTP [Response]. - Future getServerFeaturesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/features'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerFeatures() async { - final response = await getServerFeaturesWithHttpInfo(); + /// * [num] count: + Future?> getRandom({ num? count, }) async { + final response = await getRandomWithHttpInfo( count: count, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -142,272 +66,11 @@ class DeprecatedApi { // 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), 'ServerFeaturesDto',) as ServerFeaturesDto; - - } - return null; - } + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerStatisticsWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/statistics'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerStatistics() async { - final response = await getServerStatisticsWithHttpInfo(); - 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), 'ServerStatsResponseDto',) as ServerStatsResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerVersionWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/version'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerVersion() async { - final response = await getServerVersionWithHttpInfo(); - 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), 'ServerVersionResponseDto',) as ServerVersionResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getStorageWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/storage'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getStorage() async { - final response = await getStorageWithHttpInfo(); - 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getSupportedMediaTypesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/media-types'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getSupportedMediaTypes() async { - final response = await getSupportedMediaTypesWithHttpInfo(); - 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), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getThemeWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/theme'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getTheme() async { - final response = await getThemeWithHttpInfo(); - 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), 'ServerThemeDto',) as ServerThemeDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future pingServerWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/ping'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future pingServer() async { - final response = await pingServerWithHttpInfo(); - 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), 'ServerPingResponse',) as ServerPingResponse; - } return null; } diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126..78afc15c93 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 53ab0e19ce..36d98d9a88 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -243,13 +243,13 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - Future removeOfflineFilesWithHttpInfo(String id,) async { + Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/removeOffline' + final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -276,52 +276,8 @@ class LibrariesApi { /// Parameters: /// /// * [String] id (required): - Future removeOfflineFiles(String id,) async { - final response = await removeOfflineFilesWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/scan' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody = scanLibraryDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { - final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + Future scanLibrary(String id,) async { + final response = await scanLibraryWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 2846dae6c3..9644fbfc5c 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -105,62 +105,6 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - 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), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index a3506b9bc1..0681d58247 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -48,10 +48,18 @@ class NotificationsApi { /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): - Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); 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), 'TestEmailResponseDto',) as TestEmailResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 9fe62f0841..7df0d66c79 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -180,57 +180,6 @@ class PeopleApi { return null; } - /// Performs an HTTP 'GET /people/{id}/assets' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getPersonAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/people/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future?> getPersonAssets(String id,) async { - final response = await getPersonAssetsWithHttpInfo(id,); - 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 'GET /people/{id}/statistics' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 21af2d57cb..985029f106 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -111,12 +111,15 @@ class SearchApi { /// /// * [String] country: /// + /// * [bool] includeNull: + /// This property was added in v111.0.0 + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async { + Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { // ignore: prefer_const_declarations final path = r'/search/suggestions'; @@ -130,6 +133,9 @@ class SearchApi { if (country != null) { queryParams.addAll(_queryParams('', 'country', country)); } + if (includeNull != null) { + queryParams.addAll(_queryParams('', 'includeNull', includeNull)); + } if (make != null) { queryParams.addAll(_queryParams('', 'make', make)); } @@ -161,13 +167,16 @@ class SearchApi { /// /// * [String] country: /// + /// * [bool] includeNull: + /// This property was added in v111.0.0 + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async { - final response = await getSearchSuggestionsWithHttpInfo(type, country: country, make: make, model: model, state: state, ); + Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { + final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, make: make, model: model, state: state, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -342,6 +351,56 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/random' operation and returns the [Response]. + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { + // ignore: prefer_const_declarations + final path = r'/search/random'; + + // ignore: prefer_final_locals + Object? postBody = randomSearchDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { + final response = await searchRandomWithHttpInfo(randomSearchDto,); + 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 /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 9cb52514c2..7a832ad61a 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -49,6 +49,129 @@ 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'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAboutInfo() async { + final response = await getAboutInfoWithHttpInfo(); + 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), 'ServerAboutResponseDto',) as ServerAboutResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/config' operation and returns the [Response]. + Future getServerConfigWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/config'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerConfig() async { + final response = await getServerConfigWithHttpInfo(); + 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), 'ServerConfigDto',) as ServerConfigDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/features' operation and returns the [Response]. + Future getServerFeaturesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/features'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerFeatures() async { + final response = await getServerFeaturesWithHttpInfo(); + 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), 'ServerFeaturesDto',) as ServerFeaturesDto; + + } + return null; + } + /// Performs an HTTP 'GET /server/license' operation and returns the [Response]. Future getServerLicenseWithHttpInfo() async { // ignore: prefer_const_declarations @@ -90,6 +213,296 @@ class ServerApi { return null; } + /// Performs an HTTP 'GET /server/statistics' operation and returns the [Response]. + Future getServerStatisticsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerStatistics() async { + final response = await getServerStatisticsWithHttpInfo(); + 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), 'ServerStatsResponseDto',) as ServerStatsResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/version' operation and returns the [Response]. + Future getServerVersionWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/version'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getServerVersion() async { + final response = await getServerVersionWithHttpInfo(); + 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), 'ServerVersionResponseDto',) as ServerVersionResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/storage' operation and returns the [Response]. + Future getStorageWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/storage'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getStorage() async { + final response = await getStorageWithHttpInfo(); + 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; + + } + return null; + } + + /// 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'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getSupportedMediaTypes() async { + final response = await getSupportedMediaTypesWithHttpInfo(); + 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), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /server/theme' operation and returns the [Response]. + Future getThemeWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/theme'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getTheme() async { + final response = await getThemeWithHttpInfo(); + 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), 'ServerThemeDto',) as ServerThemeDto; + + } + return null; + } + + /// 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'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getVersionHistory() async { + final response = await getVersionHistoryWithHttpInfo(); + 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 'GET /server/ping' operation and returns the [Response]. + Future pingServerWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/ping'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future pingServer() async { + final response = await pingServerWithHttpInfo(); + 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), 'ServerPingResponse',) as ServerPingResponse; + + } + return null; + } + /// Performs an HTTP 'PUT /server/license' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart deleted file mode 100644 index dc58a94fd0..0000000000 --- a/mobile/openapi/lib/api/server_info_api.dart +++ /dev/null @@ -1,414 +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 ServerInfoApi { - ServerInfoApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; - - final ApiClient apiClient; - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getAboutInfoWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/about'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getAboutInfo() async { - final response = await getAboutInfoWithHttpInfo(); - 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), 'ServerAboutResponseDto',) as ServerAboutResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerConfigWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/config'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerConfig() async { - final response = await getServerConfigWithHttpInfo(); - 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), 'ServerConfigDto',) as ServerConfigDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerFeaturesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/features'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerFeatures() async { - final response = await getServerFeaturesWithHttpInfo(); - 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), 'ServerFeaturesDto',) as ServerFeaturesDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerStatisticsWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/statistics'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerStatistics() async { - final response = await getServerStatisticsWithHttpInfo(); - 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), 'ServerStatsResponseDto',) as ServerStatsResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerVersionWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/version'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getServerVersion() async { - final response = await getServerVersionWithHttpInfo(); - 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), 'ServerVersionResponseDto',) as ServerVersionResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getStorageWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/storage'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getStorage() async { - final response = await getStorageWithHttpInfo(); - 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getSupportedMediaTypesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/media-types'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getSupportedMediaTypes() async { - final response = await getSupportedMediaTypesWithHttpInfo(); - 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), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getThemeWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/theme'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future getTheme() async { - final response = await getThemeWithHttpInfo(); - 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), 'ServerThemeDto',) as ServerThemeDto; - - } - return null; - } - - /// This property was deprecated in v1.107.0 - /// - /// Note: This method returns the HTTP [Response]. - Future pingServerWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info/ping'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.107.0 - Future pingServer() async { - final response = await pingServerWithHttpInfo(); - 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), 'ServerPingResponse',) as ServerPingResponse; - - } - return null; - } -} diff --git a/mobile/openapi/lib/api/stacks_api.dart b/mobile/openapi/lib/api/stacks_api.dart new file mode 100644 index 0000000000..aa1d9b3416 --- /dev/null +++ b/mobile/openapi/lib/api/stacks_api.dart @@ -0,0 +1,298 @@ +// +// 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 StacksApi { + StacksApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStackWithHttpInfo(StackCreateDto stackCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = stackCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [StackCreateDto] stackCreateDto (required): + Future createStack(StackCreateDto stackCreateDto,) async { + final response = await createStackWithHttpInfo(stackCreateDto,); + 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), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteStack(String id,) async { + final response = await deleteStackWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacksWithHttpInfo(BulkIdsDto bulkIdsDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody = bulkIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [BulkIdsDto] bulkIdsDto (required): + Future deleteStacks(BulkIdsDto bulkIdsDto,) async { + final response = await deleteStacksWithHttpInfo(bulkIdsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getStackWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getStack(String id,) async { + final response = await getStackWithHttpInfo(id,); + 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), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /stacks' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] primaryAssetId: + Future searchStacksWithHttpInfo({ String? primaryAssetId, }) async { + // ignore: prefer_const_declarations + final path = r'/stacks'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (primaryAssetId != null) { + queryParams.addAll(_queryParams('', 'primaryAssetId', primaryAssetId)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] primaryAssetId: + Future?> searchStacks({ String? primaryAssetId, }) async { + final response = await searchStacksWithHttpInfo( primaryAssetId: primaryAssetId, ); + 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 'PUT /stacks/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/stacks/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = stackUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [StackUpdateDto] stackUpdateDto (required): + Future updateStack(String id, StackUpdateDto stackUpdateDto,) async { + final response = await updateStackWithHttpInfo(id, stackUpdateDto,); + 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), 'StackResponseDto',) as StackResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index e5d1e9c650..87c9001a3c 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -16,16 +16,63 @@ class TagsApi { final ApiClient apiClient; + /// Performs an HTTP 'PUT /tags/assets' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags/assets'; + + // ignore: prefer_final_locals + Object? postBody = tagBulkAssetsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssets(TagBulkAssetsDto tagBulkAssetsDto,) async { + final response = await bulkTagAssetsWithHttpInfo(tagBulkAssetsDto,); + 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), 'TagBulkAssetsResponseDto',) as TagBulkAssetsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /tags' operation and returns the [Response]. /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTagWithHttpInfo(CreateTagDto createTagDto,) async { + /// * [TagCreateDto] tagCreateDto (required): + Future createTagWithHttpInfo(TagCreateDto tagCreateDto,) async { // ignore: prefer_const_declarations final path = r'/tags'; // ignore: prefer_final_locals - Object? postBody = createTagDto; + Object? postBody = tagCreateDto; final queryParams = []; final headerParams = {}; @@ -47,9 +94,9 @@ class TagsApi { /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTag(CreateTagDto createTagDto,) async { - final response = await createTagWithHttpInfo(createTagDto,); + /// * [TagCreateDto] tagCreateDto (required): + Future createTag(TagCreateDto tagCreateDto,) async { + final response = await createTagWithHttpInfo(tagCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -147,57 +194,6 @@ class TagsApi { return null; } - /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getTagAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future?> getTagAssets(String id,) async { - final response = await getTagAssetsWithHttpInfo(id,); - 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 'GET /tags/{id}' operation and returns the [Response]. /// Parameters: /// @@ -251,14 +247,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -282,9 +278,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> tagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await tagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> tagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await tagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -293,8 +289,8 @@ class TagsApi { // 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() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -306,14 +302,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -337,9 +333,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> untagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await untagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> untagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await untagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -348,27 +344,27 @@ class TagsApi { // 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() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } return null; } - /// Performs an HTTP 'PATCH /tags/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async { + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = updateTagDto; + Object? postBody = tagUpdateDto; final queryParams = []; final headerParams = {}; @@ -379,7 +375,7 @@ class TagsApi { return apiClient.invokeAPI( path, - 'PATCH', + 'PUT', queryParams, postBody, headerParams, @@ -392,9 +388,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTag(String id, UpdateTagDto updateTagDto,) async { - final response = await updateTagWithHttpInfo(id, updateTagDto,); + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTag(String id, TagUpdateDto tagUpdateDto,) async { + final response = await updateTagWithHttpInfo(id, tagUpdateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -407,4 +403,54 @@ class TagsApi { } return null; } + + /// Performs an HTTP 'PUT /tags' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags'; + + // ignore: prefer_final_locals + Object? postBody = tagUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future?> upsertTags(TagUpsertDto tagUpsertDto,) async { + final response = await upsertTagsWithHttpInfo(tagUpsertDto,); + 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; + } } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 4acb98bdf2..8c94e09bf5 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -37,12 +37,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + 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'; @@ -75,6 +77,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); @@ -120,13 +125,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBucket(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 { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -162,12 +169,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + 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'; @@ -200,6 +209,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -242,13 +254,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets(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 { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 9c346870ec..8f8c6ffb3a 100644 --- a/mobile/openapi/lib/api/trash_api.dart +++ b/mobile/openapi/lib/api/trash_api.dart @@ -42,11 +42,19 @@ class TrashApi { ); } - Future emptyTrash() async { + Future emptyTrash() async { final response = await emptyTrashWithHttpInfo(); 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), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } /// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response]. @@ -81,11 +89,19 @@ class TrashApi { /// Parameters: /// /// * [BulkIdsDto] bulkIdsDto (required): - Future restoreAssets(BulkIdsDto bulkIdsDto,) async { + Future restoreAssets(BulkIdsDto bulkIdsDto,) async { final response = await restoreAssetsWithHttpInfo(bulkIdsDto,); 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), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } /// Performs an HTTP 'POST /trash/restore' operation and returns the [Response]. @@ -114,10 +130,18 @@ class TrashApi { ); } - Future restoreTrash() async { + Future restoreTrash() async { final response = await restoreTrashWithHttpInfo(); 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), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart new file mode 100644 index 0000000000..f4489f2d1a --- /dev/null +++ b/mobile/openapi/lib/api/view_api.dart @@ -0,0 +1,114 @@ +// +// 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 ViewApi { + ViewApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /view/folder' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] path (required): + Future getAssetsByOriginalPathWithHttpInfo(String path,) async { + // ignore: prefer_const_declarations + final path = r'/view/folder'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'path', path)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] path (required): + Future?> getAssetsByOriginalPath(String path,) async { + final response = await getAssetsByOriginalPathWithHttpInfo(path,); + 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 'GET /view/folder/unique-paths' operation and returns the [Response]. + Future getUniqueOriginalPathsWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/view/folder/unique-paths'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getUniqueOriginalPaths() async { + final response = await getUniqueOriginalPathsWithHttpInfo(); + 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; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4fe810b886..b6ddf86e70 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -200,10 +200,10 @@ class ApiClient { return AddUsersDto.fromJson(value); case 'AdminOnboardingUpdateDto': return AdminOnboardingUpdateDto.fromJson(value); - case 'AlbumCountResponseDto': - return AlbumCountResponseDto.fromJson(value); case 'AlbumResponseDto': return AlbumResponseDto.fromJson(value); + case 'AlbumStatisticsResponseDto': + return AlbumStatisticsResponseDto.fromJson(value); case 'AlbumUserAddDto': return AlbumUserAddDto.fromJson(value); case 'AlbumUserCreateDto': @@ -258,6 +258,8 @@ class ApiClient { return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); + case 'AssetStackResponseDto': + return AssetStackResponseDto.fromJson(value); case 'AssetStatsResponseDto': return AssetStatsResponseDto.fromJson(value); case 'AssetTypeEnum': @@ -292,8 +294,8 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); - case 'CreateTagDto': - return CreateTagDto.fromJson(value); + case 'DatabaseBackupConfig': + return DatabaseBackupConfig.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -330,6 +332,10 @@ class ApiClient { return FileReportFixDto.fromJson(value); case 'FileReportItemDto': return FileReportItemDto.fromJson(value); + case 'FoldersResponse': + return FoldersResponse.fromJson(value); + case 'FoldersUpdate': + return FoldersUpdate.fromJson(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); case 'JobCommand': @@ -338,6 +344,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -360,24 +368,24 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': return MapReverseGeocodeResponseDto.fromJson(value); - case 'MapTheme': - return MapThemeTypeTransformer().decode(value); + case 'MemoriesResponse': + return MemoriesResponse.fromJson(value); + case 'MemoriesUpdate': + return MemoriesUpdate.fromJson(value); case 'MemoryCreateDto': return MemoryCreateDto.fromJson(value); case 'MemoryLaneResponseDto': return MemoryLaneResponseDto.fromJson(value); - case 'MemoryResponse': - return MemoryResponse.fromJson(value); case 'MemoryResponseDto': return MemoryResponseDto.fromJson(value); case 'MemoryType': return MemoryTypeTypeTransformer().decode(value); - case 'MemoryUpdate': - return MemoryUpdate.fromJson(value); case 'MemoryUpdateDto': return MemoryUpdateDto.fromJson(value); case 'MergePersonDto': @@ -400,12 +408,18 @@ class ApiClient { return PathEntityTypeTypeTransformer().decode(value); case 'PathType': return PathTypeTypeTransformer().decode(value); + case 'PeopleResponse': + return PeopleResponse.fromJson(value); case 'PeopleResponseDto': return PeopleResponseDto.fromJson(value); + case 'PeopleUpdate': + return PeopleUpdate.fromJson(value); case 'PeopleUpdateDto': return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'Permission': + return PermissionTypeTransformer().decode(value); case 'PersonCreateDto': return PersonCreateDto.fromJson(value); case 'PersonResponseDto': @@ -424,14 +438,18 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RandomSearchDto': + return RandomSearchDto.fromJson(value); + case 'RatingsResponse': + return RatingsResponse.fromJson(value); + case 'RatingsUpdate': + return RatingsUpdate.fromJson(value); case 'ReactionLevel': return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); - case 'ScanLibraryDto': - return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': @@ -464,6 +482,8 @@ class ApiClient { return ServerStorageResponseDto.fromJson(value); case 'ServerThemeDto': return ServerThemeDto.fromJson(value); + case 'ServerVersionHistoryResponseDto': + return ServerVersionHistoryResponseDto.fromJson(value); case 'ServerVersionResponseDto': return ServerVersionResponseDto.fromJson(value); case 'SessionResponseDto': @@ -482,10 +502,24 @@ class ApiClient { return SmartInfoResponseDto.fromJson(value); case 'SmartSearchDto': return SmartSearchDto.fromJson(value); + case 'SourceType': + return SourceTypeTypeTransformer().decode(value); + case 'StackCreateDto': + return StackCreateDto.fromJson(value); + case 'StackResponseDto': + return StackResponseDto.fromJson(value); + case 'StackUpdateDto': + return StackUpdateDto.fromJson(value); + case 'SystemConfigBackupsDto': + return SystemConfigBackupsDto.fromJson(value); case 'SystemConfigDto': return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': return SystemConfigFFmpegDto.fromJson(value); + case 'SystemConfigFacesDto': + return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': @@ -502,6 +536,8 @@ class ApiClient { return SystemConfigMachineLearningDto.fromJson(value); case 'SystemConfigMapDto': return SystemConfigMapDto.fromJson(value); + case 'SystemConfigMetadataDto': + return SystemConfigMetadataDto.fromJson(value); case 'SystemConfigNewVersionCheckDto': return SystemConfigNewVersionCheckDto.fromJson(value); case 'SystemConfigNotificationsDto': @@ -528,10 +564,24 @@ class ApiClient { return SystemConfigTrashDto.fromJson(value); case 'SystemConfigUserDto': return SystemConfigUserDto.fromJson(value); + case 'TagBulkAssetsDto': + return TagBulkAssetsDto.fromJson(value); + case 'TagBulkAssetsResponseDto': + return TagBulkAssetsResponseDto.fromJson(value); + case 'TagCreateDto': + return TagCreateDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); - case 'TagTypeEnum': - return TagTypeEnumTypeTransformer().decode(value); + case 'TagUpdateDto': + return TagUpdateDto.fromJson(value); + case 'TagUpsertDto': + return TagUpsertDto.fromJson(value); + case 'TagsResponse': + return TagsResponse.fromJson(value); + case 'TagsUpdate': + return TagsUpdate.fromJson(value); + case 'TestEmailResponseDto': + return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': @@ -542,6 +592,8 @@ class ApiClient { return TranscodeHWAccelTypeTransformer().decode(value); case 'TranscodePolicy': return TranscodePolicyTypeTransformer().decode(value); + case 'TrashResponseDto': + return TrashResponseDto.fromJson(value); case 'UpdateAlbumDto': return UpdateAlbumDto.fromJson(value); case 'UpdateAlbumUserDto': @@ -552,10 +604,6 @@ class ApiClient { return UpdateLibraryDto.fromJson(value); case 'UpdatePartnerDto': return UpdatePartnerDto.fromJson(value); - case 'UpdateStackParentDto': - return UpdateStackParentDto.fromJson(value); - case 'UpdateTagDto': - return UpdateTagDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); case 'UserAdminCreateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 04fcaa3463..b7c6ad5e01 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,8 +97,8 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } - if (value is MapTheme) { - return MapThemeTypeTransformer().encode(value).toString(); + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); @@ -112,6 +112,9 @@ String parameterToString(dynamic value) { if (value is PathType) { return PathTypeTypeTransformer().encode(value).toString(); } + if (value is Permission) { + return PermissionTypeTransformer().encode(value).toString(); + } if (value is ReactionLevel) { return ReactionLevelTypeTransformer().encode(value).toString(); } @@ -124,8 +127,8 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } - if (value is TagTypeEnum) { - return TagTypeEnumTypeTransformer().encode(value).toString(); + if (value is SourceType) { + return SourceTypeTypeTransformer().encode(value).toString(); } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index b54fa2ca72..ce4b4a0176 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -78,6 +78,7 @@ class ActivityCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityCreateDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index bfffd8485b..25fb0f53f8 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -78,6 +78,7 @@ class ActivityResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 20d4696b1b..ad0b814a58 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 2daa571265..531c1ec785 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -40,6 +40,7 @@ class AddUsersDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AddUsersDto? fromJson(dynamic value) { + upgradeDto(value, "AddUsersDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 2277f0958c..298bf318a2 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AdminOnboardingUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AdminOnboardingUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index c98a95775d..547a6a70fd 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -186,6 +186,7 @@ class AlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_count_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart similarity index 62% rename from mobile/openapi/lib/model/album_count_response_dto.dart rename to mobile/openapi/lib/model/album_statistics_response_dto.dart index 531a17a083..9e19002cf1 100644 --- a/mobile/openapi/lib/model/album_count_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class AlbumCountResponseDto { - /// Returns a new [AlbumCountResponseDto] instance. - AlbumCountResponseDto({ +class AlbumStatisticsResponseDto { + /// Returns a new [AlbumStatisticsResponseDto] instance. + AlbumStatisticsResponseDto({ required this.notShared, required this.owned, required this.shared, @@ -25,7 +25,7 @@ class AlbumCountResponseDto { int shared; @override - bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto && + bool operator ==(Object other) => identical(this, other) || other is AlbumStatisticsResponseDto && other.notShared == notShared && other.owned == owned && other.shared == shared; @@ -38,7 +38,7 @@ class AlbumCountResponseDto { (shared.hashCode); @override - String toString() => 'AlbumCountResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; + String toString() => 'AlbumStatisticsResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; Map toJson() { final json = {}; @@ -48,14 +48,15 @@ class AlbumCountResponseDto { return json; } - /// Returns a new [AlbumCountResponseDto] instance and imports its values from + /// Returns a new [AlbumStatisticsResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AlbumCountResponseDto? fromJson(dynamic value) { + static AlbumStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumStatisticsResponseDto"); if (value is Map) { final json = value.cast(); - return AlbumCountResponseDto( + return AlbumStatisticsResponseDto( notShared: mapValueOfType(json, r'notShared')!, owned: mapValueOfType(json, r'owned')!, shared: mapValueOfType(json, r'shared')!, @@ -64,11 +65,11 @@ class AlbumCountResponseDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AlbumCountResponseDto.fromJson(row); + final value = AlbumStatisticsResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -77,12 +78,12 @@ class AlbumCountResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = AlbumCountResponseDto.fromJson(entry.value); + final value = AlbumStatisticsResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -91,14 +92,14 @@ class AlbumCountResponseDto { return map; } - // maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of AlbumStatisticsResponseDto-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] = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = AlbumStatisticsResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index e654a2ff5d..3f72d5c893 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -56,6 +56,7 @@ class AlbumUserAddDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserAddDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserAddDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 708acd472b..93a0661b30 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -46,6 +46,7 @@ class AlbumUserCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 8f86cf254e..bbae03fba7 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -46,6 +46,7 @@ class AlbumUserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 1ee5253c38..787d02dd0e 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -14,6 +14,7 @@ class AllJobStatusResponseDto { /// Returns a new [AllJobStatusResponseDto] instance. AllJobStatusResponseDto({ required this.backgroundTask, + required this.backupDatabase, required this.duplicateDetection, required this.faceDetection, required this.facialRecognition, @@ -31,6 +32,8 @@ class AllJobStatusResponseDto { JobStatusDto backgroundTask; + JobStatusDto backupDatabase; + JobStatusDto duplicateDetection; JobStatusDto faceDetection; @@ -60,6 +63,7 @@ class AllJobStatusResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && other.backgroundTask == backgroundTask && + other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && other.faceDetection == faceDetection && other.facialRecognition == facialRecognition && @@ -78,6 +82,7 @@ class AllJobStatusResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + + (backupDatabase.hashCode) + (duplicateDetection.hashCode) + (faceDetection.hashCode) + (facialRecognition.hashCode) + @@ -93,11 +98,12 @@ class AllJobStatusResponseDto { (videoConversion.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; + json[r'backupDatabase'] = this.backupDatabase; json[r'duplicateDetection'] = this.duplicateDetection; json[r'faceDetection'] = this.faceDetection; json[r'facialRecognition'] = this.facialRecognition; @@ -118,11 +124,13 @@ class AllJobStatusResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AllJobStatusResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AllJobStatusResponseDto"); if (value is Map) { final json = value.cast(); return AllJobStatusResponseDto( backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, + backupDatabase: JobStatusDto.fromJson(json[r'backupDatabase'])!, duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!, faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!, facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!, @@ -184,6 +192,7 @@ class AllJobStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backgroundTask', + 'backupDatabase', 'duplicateDetection', 'faceDetection', 'facialRecognition', diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index f6ff8e5f97..848774e9c9 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -14,6 +14,7 @@ class APIKeyCreateDto { /// Returns a new [APIKeyCreateDto] instance. APIKeyCreateDto({ this.name, + this.permissions = const [], }); /// @@ -24,17 +25,21 @@ class APIKeyCreateDto { /// String? name; + List permissions; + @override bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto && - other.name == name; + other.name == name && + _deepEquality.equals(other.permissions, permissions); @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (name == null ? 0 : name!.hashCode) + + (permissions.hashCode); @override - String toString() => 'APIKeyCreateDto[name=$name]'; + String toString() => 'APIKeyCreateDto[name=$name, permissions=$permissions]'; Map toJson() { final json = {}; @@ -43,6 +48,7 @@ class APIKeyCreateDto { } else { // json[r'name'] = null; } + json[r'permissions'] = this.permissions; return json; } @@ -50,11 +56,13 @@ class APIKeyCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateDto"); if (value is Map) { final json = value.cast(); return APIKeyCreateDto( name: mapValueOfType(json, r'name'), + permissions: Permission.listFromJson(json[r'permissions']), ); } return null; @@ -102,6 +110,7 @@ class APIKeyCreateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'permissions', }; } diff --git a/mobile/openapi/lib/model/api_key_create_response_dto.dart b/mobile/openapi/lib/model/api_key_create_response_dto.dart index 93065654ac..cdaa70e37d 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -46,6 +46,7 @@ class APIKeyCreateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index 764d5ec973..fd0d91f673 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -16,6 +16,7 @@ class APIKeyResponseDto { required this.createdAt, required this.id, required this.name, + this.permissions = const [], required this.updatedAt, }); @@ -25,6 +26,8 @@ class APIKeyResponseDto { String name; + List permissions; + DateTime updatedAt; @override @@ -32,6 +35,7 @@ class APIKeyResponseDto { other.createdAt == createdAt && other.id == id && other.name == name && + _deepEquality.equals(other.permissions, permissions) && other.updatedAt == updatedAt; @override @@ -40,16 +44,18 @@ class APIKeyResponseDto { (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + + (permissions.hashCode) + (updatedAt.hashCode); @override - String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt]'; + String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, permissions=$permissions, updatedAt=$updatedAt]'; Map toJson() { final json = {}; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; + json[r'permissions'] = this.permissions; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -58,6 +64,7 @@ class APIKeyResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyResponseDto"); if (value is Map) { final json = value.cast(); @@ -65,6 +72,7 @@ class APIKeyResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + permissions: Permission.listFromJson(json[r'permissions']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } @@ -116,6 +124,7 @@ class APIKeyResponseDto { 'createdAt', 'id', 'name', + 'permissions', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 318f4936e1..7295d1ea1f 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -40,6 +40,7 @@ class APIKeyUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 0f6913a7f4..c4453054b1 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -56,6 +56,7 @@ class AssetBulkDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index dcab64e1f3..da23d2f09d 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -20,8 +20,7 @@ class AssetBulkUpdateDto { this.isFavorite, this.latitude, this.longitude, - this.removeParent, - this.stackParentId, + this.rating, }); /// @@ -68,21 +67,15 @@ class AssetBulkUpdateDto { /// num? longitude; + /// Minimum value: 0 + /// 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. /// - bool? removeParent; - - /// - /// 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? stackParentId; + num? rating; @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && @@ -93,8 +86,7 @@ class AssetBulkUpdateDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && - other.removeParent == removeParent && - other.stackParentId == stackParentId; + other.rating == rating; @override int get hashCode => @@ -106,11 +98,10 @@ class AssetBulkUpdateDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + - (removeParent == null ? 0 : removeParent!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode); + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -145,15 +136,10 @@ class AssetBulkUpdateDto { } else { // json[r'longitude'] = null; } - if (this.removeParent != null) { - json[r'removeParent'] = this.removeParent; + if (this.rating != null) { + json[r'rating'] = this.rating; } else { - // json[r'removeParent'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; + // json[r'rating'] = null; } return json; } @@ -162,6 +148,7 @@ class AssetBulkUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUpdateDto"); if (value is Map) { final json = value.cast(); @@ -175,8 +162,7 @@ class AssetBulkUpdateDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), - removeParent: mapValueOfType(json, r'removeParent'), - stackParentId: mapValueOfType(json, r'stackParentId'), + rating: num.parse('${json[r'rating']}'), ); } return null; diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart index 55ea41b598..36c13bfdf6 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart index 16294cdae6..13dfa340fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckItem? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart index 5bfacbff57..8c3651e9fa 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index 737186e589..88e46dae7d 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -16,6 +16,7 @@ class AssetBulkUploadCheckResult { required this.action, this.assetId, required this.id, + this.isTrashed, this.reason, }); @@ -31,6 +32,14 @@ class AssetBulkUploadCheckResult { 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? isTrashed; + AssetBulkUploadCheckResultReasonEnum? reason; @override @@ -38,6 +47,7 @@ class AssetBulkUploadCheckResult { other.action == action && other.assetId == assetId && other.id == id && + other.isTrashed == isTrashed && other.reason == reason; @override @@ -46,10 +56,11 @@ class AssetBulkUploadCheckResult { (action.hashCode) + (assetId == null ? 0 : assetId!.hashCode) + (id.hashCode) + + (isTrashed == null ? 0 : isTrashed!.hashCode) + (reason == null ? 0 : reason!.hashCode); @override - String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, reason=$reason]'; + String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, isTrashed=$isTrashed, reason=$reason]'; Map toJson() { final json = {}; @@ -60,6 +71,11 @@ class AssetBulkUploadCheckResult { // json[r'assetId'] = null; } json[r'id'] = this.id; + if (this.isTrashed != null) { + json[r'isTrashed'] = this.isTrashed; + } else { + // json[r'isTrashed'] = null; + } if (this.reason != null) { json[r'reason'] = this.reason; } else { @@ -72,6 +88,7 @@ class AssetBulkUploadCheckResult { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResult? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResult"); if (value is Map) { final json = value.cast(); @@ -79,6 +96,7 @@ class AssetBulkUploadCheckResult { action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!, assetId: mapValueOfType(json, r'assetId'), id: mapValueOfType(json, r'id')!, + isTrashed: mapValueOfType(json, r'isTrashed'), reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']), ); } diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index a5ee10f33e..845aadcdcd 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -46,6 +46,7 @@ class AssetDeltaSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart index 3b14fa68cf..a64e1a2fbe 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 812b165caa..c05b511649 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -21,6 +21,7 @@ class AssetFaceResponseDto { required this.imageHeight, required this.imageWidth, required this.person, + this.sourceType, }); int boundingBoxX1; @@ -39,6 +40,14 @@ class AssetFaceResponseDto { PersonResponseDto? person; + /// + /// 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. + /// + SourceType? sourceType; + @override bool operator ==(Object other) => identical(this, other) || other is AssetFaceResponseDto && other.boundingBoxX1 == boundingBoxX1 && @@ -48,7 +57,8 @@ class AssetFaceResponseDto { other.id == id && other.imageHeight == imageHeight && other.imageWidth == imageWidth && - other.person == person; + other.person == person && + other.sourceType == sourceType; @override int get hashCode => @@ -60,10 +70,11 @@ class AssetFaceResponseDto { (id.hashCode) + (imageHeight.hashCode) + (imageWidth.hashCode) + - (person == null ? 0 : person!.hashCode); + (person == null ? 0 : person!.hashCode) + + (sourceType == null ? 0 : sourceType!.hashCode); @override - String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person]'; + String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person, sourceType=$sourceType]'; Map toJson() { final json = {}; @@ -79,6 +90,11 @@ class AssetFaceResponseDto { } else { // json[r'person'] = null; } + if (this.sourceType != null) { + json[r'sourceType'] = this.sourceType; + } else { + // json[r'sourceType'] = null; + } return json; } @@ -86,6 +102,7 @@ class AssetFaceResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceResponseDto"); if (value is Map) { final json = value.cast(); @@ -98,6 +115,7 @@ class AssetFaceResponseDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, person: PersonResponseDto.fromJson(json[r'person']), + sourceType: SourceType.fromJson(json[r'sourceType']), ); } return null; diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 58def49ae1..71bdde8e9a 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -40,6 +40,7 @@ class AssetFaceUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 5ea37ea4db..c2c4803259 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -46,6 +46,7 @@ class AssetFaceUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index 893f8ff353..8bf07e1534 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -20,6 +20,7 @@ class AssetFaceWithoutPersonResponseDto { required this.id, required this.imageHeight, required this.imageWidth, + this.sourceType, }); int boundingBoxX1; @@ -36,6 +37,14 @@ class AssetFaceWithoutPersonResponseDto { int imageWidth; + /// + /// 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. + /// + SourceType? sourceType; + @override bool operator ==(Object other) => identical(this, other) || other is AssetFaceWithoutPersonResponseDto && other.boundingBoxX1 == boundingBoxX1 && @@ -44,7 +53,8 @@ class AssetFaceWithoutPersonResponseDto { other.boundingBoxY2 == boundingBoxY2 && other.id == id && other.imageHeight == imageHeight && - other.imageWidth == imageWidth; + other.imageWidth == imageWidth && + other.sourceType == sourceType; @override int get hashCode => @@ -55,10 +65,11 @@ class AssetFaceWithoutPersonResponseDto { (boundingBoxY2.hashCode) + (id.hashCode) + (imageHeight.hashCode) + - (imageWidth.hashCode); + (imageWidth.hashCode) + + (sourceType == null ? 0 : sourceType!.hashCode); @override - String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth]'; + String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, sourceType=$sourceType]'; Map toJson() { final json = {}; @@ -69,6 +80,11 @@ class AssetFaceWithoutPersonResponseDto { json[r'id'] = this.id; json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; + if (this.sourceType != null) { + json[r'sourceType'] = this.sourceType; + } else { + // json[r'sourceType'] = null; + } return json; } @@ -76,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); if (value is Map) { final json = value.cast(); @@ -87,6 +104,7 @@ class AssetFaceWithoutPersonResponseDto { id: mapValueOfType(json, r'id')!, imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, + sourceType: SourceType.fromJson(json[r'sourceType']), ); } return null; diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index e80638f6b0..7151094b95 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -79,6 +79,7 @@ class AssetFullSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFullSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFullSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index c8c7a69b89..b44888f396 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -40,6 +40,7 @@ class AssetIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index a642c0924c..ff63091caa 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -56,6 +56,7 @@ class AssetIdsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_job_name.dart b/mobile/openapi/lib/model/asset_job_name.dart index a5b42f4ee5..11e0555b86 100644 --- a/mobile/openapi/lib/model/asset_job_name.dart +++ b/mobile/openapi/lib/model/asset_job_name.dart @@ -23,14 +23,16 @@ class AssetJobName { String toJson() => value; - static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail'); + static const refreshFaces = AssetJobName._(r'refresh-faces'); static const refreshMetadata = AssetJobName._(r'refresh-metadata'); + static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail'); static const transcodeVideo = AssetJobName._(r'transcode-video'); /// List of all possible values in this [enum][AssetJobName]. static const values = [ - regenerateThumbnail, + refreshFaces, refreshMetadata, + regenerateThumbnail, transcodeVideo, ]; @@ -70,8 +72,9 @@ class AssetJobNameTypeTransformer { AssetJobName? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'regenerate-thumbnail': return AssetJobName.regenerateThumbnail; + case r'refresh-faces': return AssetJobName.refreshFaces; case r'refresh-metadata': return AssetJobName.refreshMetadata; + case r'regenerate-thumbnail': return AssetJobName.regenerateThumbnail; case r'transcode-video': return AssetJobName.transcodeVideo; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 16ed2644fd..0f8bfab009 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -46,6 +46,7 @@ class AssetJobsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetJobsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetJobsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart index c2801c93cc..75428ec5f6 100644 --- a/mobile/openapi/lib/model/asset_media_response_dto.dart +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -46,6 +46,7 @@ class AssetMediaResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetMediaResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMediaResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 61e33ef4e0..c11dedcbfd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -36,11 +36,9 @@ class AssetResponseDto { this.owner, required this.ownerId, this.people = const [], - required this.resized, + this.resized, this.smartInfo, - this.stack = const [], - required this.stackCount, - this.stackParentId, + this.stack, this.tags = const [], required this.thumbhash, required this.type, @@ -114,7 +112,14 @@ class AssetResponseDto { List people; - bool resized; + /// This property was deprecated in v1.113.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? resized; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -124,11 +129,7 @@ class AssetResponseDto { /// SmartInfoResponseDto? smartInfo; - List stack; - - int? stackCount; - - String? stackParentId; + AssetStackResponseDto? stack; List tags; @@ -167,9 +168,7 @@ class AssetResponseDto { _deepEquality.equals(other.people, people) && other.resized == resized && other.smartInfo == smartInfo && - _deepEquality.equals(other.stack, stack) && - other.stackCount == stackCount && - other.stackParentId == stackParentId && + other.stack == stack && _deepEquality.equals(other.tags, tags) && other.thumbhash == thumbhash && other.type == type && @@ -202,11 +201,9 @@ class AssetResponseDto { (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + (people.hashCode) + - (resized.hashCode) + + (resized == null ? 0 : resized!.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + - (stack.hashCode) + - (stackCount == null ? 0 : stackCount!.hashCode) + - (stackParentId == null ? 0 : stackParentId!.hashCode) + + (stack == null ? 0 : stack!.hashCode) + (tags.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + @@ -214,7 +211,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -265,22 +262,20 @@ class AssetResponseDto { } json[r'ownerId'] = this.ownerId; json[r'people'] = this.people; + if (this.resized != null) { json[r'resized'] = this.resized; + } else { + // json[r'resized'] = null; + } if (this.smartInfo != null) { json[r'smartInfo'] = this.smartInfo; } else { // json[r'smartInfo'] = null; } + if (this.stack != null) { json[r'stack'] = this.stack; - if (this.stackCount != null) { - json[r'stackCount'] = this.stackCount; } else { - // json[r'stackCount'] = null; - } - if (this.stackParentId != null) { - json[r'stackParentId'] = this.stackParentId; - } else { - // json[r'stackParentId'] = null; + // json[r'stack'] = null; } json[r'tags'] = this.tags; if (this.thumbhash != null) { @@ -298,6 +293,7 @@ class AssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetResponseDto"); if (value is Map) { final json = value.cast(); @@ -325,11 +321,9 @@ class AssetResponseDto { owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, people: PersonWithFacesResponseDto.listFromJson(json[r'people']), - resized: mapValueOfType(json, r'resized')!, + resized: mapValueOfType(json, r'resized'), smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), - stack: AssetResponseDto.listFromJson(json[r'stack']), - stackCount: mapValueOfType(json, r'stackCount'), - stackParentId: mapValueOfType(json, r'stackParentId'), + stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -398,8 +392,6 @@ class AssetResponseDto { 'originalFileName', 'originalPath', 'ownerId', - 'resized', - 'stackCount', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart new file mode 100644 index 0000000000..bb4becb129 --- /dev/null +++ b/mobile/openapi/lib/model/asset_stack_response_dto.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 AssetStackResponseDto { + /// Returns a new [AssetStackResponseDto] instance. + AssetStackResponseDto({ + required this.assetCount, + required this.id, + required this.primaryAssetId, + }); + + int assetCount; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetStackResponseDto && + other.assetCount == assetCount && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'AssetStackResponseDto[assetCount=$assetCount, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assetCount'] = this.assetCount; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [AssetStackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStackResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetStackResponseDto( + assetCount: mapValueOfType(json, r'assetCount')!, + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + 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 = AssetStackResponseDto.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 = AssetStackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetStackResponseDto-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] = AssetStackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetCount', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index c21d7fdbff..d11ce55a5c 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -52,6 +52,7 @@ class AssetStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart index ca195f7d06..ea1e96f36e 100644 --- a/mobile/openapi/lib/model/audio_codec.dart +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -26,12 +26,14 @@ class AudioCodec { static const mp3 = AudioCodec._(r'mp3'); static const aac = AudioCodec._(r'aac'); static const libopus = AudioCodec._(r'libopus'); + static const pcmS16le = AudioCodec._(r'pcm_s16le'); /// List of all possible values in this [enum][AudioCodec]. static const values = [ mp3, aac, libopus, + pcmS16le, ]; static AudioCodec? fromJson(dynamic value) => AudioCodecTypeTransformer().decode(value); @@ -73,6 +75,7 @@ class AudioCodecTypeTransformer { case r'mp3': return AudioCodec.mp3; case r'aac': return AudioCodec.aac; case r'libopus': return AudioCodec.libopus; + case r'pcm_s16le': return AudioCodec.pcmS16le; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 690a52e811..6b1df74eb4 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -46,6 +46,7 @@ class AuditDeletesResponseDto { /// [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(); diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart index edd242df4e..8ce0287565 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/avatar_response.dart @@ -40,6 +40,7 @@ class AvatarResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarResponse? fromJson(dynamic value) { + upgradeDto(value, "AvatarResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart index b92eb8dcbd..875eb138a8 100644 --- a/mobile/openapi/lib/model/avatar_update.dart +++ b/mobile/openapi/lib/model/avatar_update.dart @@ -50,6 +50,7 @@ class AvatarUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarUpdate? fromJson(dynamic value) { + upgradeDto(value, "AvatarUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index ef3cf2e0db..67a587e8d0 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -56,6 +56,7 @@ class BulkIdResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdResponseDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index 6942875f0a..6a7f8ceeec 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -40,6 +40,7 @@ class BulkIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdsDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 1074aaf74d..33b7f4a607 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -46,6 +46,7 @@ class ChangePasswordDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ChangePasswordDto? fromJson(dynamic value) { + upgradeDto(value, "ChangePasswordDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index 49ef36cc09..42ce6d5c3e 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -46,6 +46,7 @@ class CheckExistingAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart index d8b0f43a6d..ad93578ebc 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 6e95c15fbf..b500d20f2e 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -46,6 +46,7 @@ class CLIPConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CLIPConfig? fromJson(dynamic value) { + upgradeDto(value, "CLIPConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index fa28b782ac..ff8c1df647 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -68,6 +68,7 @@ class CreateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "CreateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 65ceec8e8a..bffa5f4279 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -68,6 +68,7 @@ class CreateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "CreateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_profile_image_response_dto.dart b/mobile/openapi/lib/model/create_profile_image_response_dto.dart index c9ae3ea651..ee98142e86 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class CreateProfileImageResponseDto { /// Returns a new [CreateProfileImageResponseDto] instance. CreateProfileImageResponseDto({ + required this.profileChangedAt, required this.profileImagePath, required this.userId, }); + DateTime profileChangedAt; + String profileImagePath; String userId; @override bool operator ==(Object other) => identical(this, other) || other is CreateProfileImageResponseDto && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath && other.userId == userId; @override int get hashCode => // ignore: unnecessary_parenthesis + (profileChangedAt.hashCode) + (profileImagePath.hashCode) + (userId.hashCode); @override - String toString() => 'CreateProfileImageResponseDto[profileImagePath=$profileImagePath, userId=$userId]'; + String toString() => 'CreateProfileImageResponseDto[profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, userId=$userId]'; Map toJson() { final json = {}; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; json[r'userId'] = this.userId; return json; @@ -46,10 +52,12 @@ class CreateProfileImageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateProfileImageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CreateProfileImageResponseDto"); if (value is Map) { final json = value.cast(); return CreateProfileImageResponseDto( + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, userId: mapValueOfType(json, r'userId')!, ); @@ -99,6 +107,7 @@ class CreateProfileImageResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'profileChangedAt', 'profileImagePath', 'userId', }; diff --git a/mobile/openapi/lib/model/database_backup_config.dart b/mobile/openapi/lib/model/database_backup_config.dart new file mode 100644 index 0000000000..d82128bd44 --- /dev/null +++ b/mobile/openapi/lib/model/database_backup_config.dart @@ -0,0 +1,116 @@ +// +// 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 DatabaseBackupConfig { + /// Returns a new [DatabaseBackupConfig] instance. + DatabaseBackupConfig({ + required this.cronExpression, + required this.enabled, + required this.keepLastAmount, + }); + + String cronExpression; + + bool enabled; + + /// Minimum value: 1 + num keepLastAmount; + + @override + bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig && + other.cronExpression == cronExpression && + other.enabled == enabled && + other.keepLastAmount == keepLastAmount; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (cronExpression.hashCode) + + (enabled.hashCode) + + (keepLastAmount.hashCode); + + @override + String toString() => 'DatabaseBackupConfig[cronExpression=$cronExpression, enabled=$enabled, keepLastAmount=$keepLastAmount]'; + + Map toJson() { + final json = {}; + json[r'cronExpression'] = this.cronExpression; + json[r'enabled'] = this.enabled; + json[r'keepLastAmount'] = this.keepLastAmount; + return json; + } + + /// Returns a new [DatabaseBackupConfig] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DatabaseBackupConfig? fromJson(dynamic value) { + upgradeDto(value, "DatabaseBackupConfig"); + if (value is Map) { + final json = value.cast(); + + return DatabaseBackupConfig( + cronExpression: mapValueOfType(json, r'cronExpression')!, + enabled: mapValueOfType(json, r'enabled')!, + keepLastAmount: num.parse('${json[r'keepLastAmount']}'), + ); + } + 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 = DatabaseBackupConfig.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 = DatabaseBackupConfig.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DatabaseBackupConfig-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] = DatabaseBackupConfig.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'cronExpression', + 'enabled', + 'keepLastAmount', + }; +} + diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index e324850bdc..5f3fd1a8c1 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -46,6 +46,7 @@ class DownloadArchiveInfo { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadArchiveInfo? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveInfo"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 4c38769010..6f4777975c 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -89,6 +89,7 @@ class DownloadInfoDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadInfoDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadInfoDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 8973e17ebe..041da44b71 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -14,25 +14,31 @@ class DownloadResponse { /// Returns a new [DownloadResponse] instance. DownloadResponse({ required this.archiveSize, + this.includeEmbeddedVideos = false, }); int archiveSize; + bool includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadResponse && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize.hashCode); + (archiveSize.hashCode) + + (includeEmbeddedVideos.hashCode); @override - String toString() => 'DownloadResponse[archiveSize=$archiveSize]'; + String toString() => 'DownloadResponse[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; json[r'archiveSize'] = this.archiveSize; + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; return json; } @@ -40,11 +46,13 @@ class DownloadResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponse? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponse"); if (value is Map) { final json = value.cast(); return DownloadResponse( archiveSize: mapValueOfType(json, r'archiveSize')!, + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos')!, ); } return null; @@ -93,6 +101,7 @@ class DownloadResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'archiveSize', + 'includeEmbeddedVideos', }; } diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index f32cba9253..5c6bd11266 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -46,6 +46,7 @@ class DownloadResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 1629706415..8df825a922 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -14,6 +14,7 @@ class DownloadUpdate { /// Returns a new [DownloadUpdate] instance. DownloadUpdate({ this.archiveSize, + this.includeEmbeddedVideos, }); /// Minimum value: 1 @@ -25,17 +26,27 @@ class DownloadUpdate { /// int? archiveSize; + /// + /// 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? includeEmbeddedVideos; + @override bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate && - other.archiveSize == archiveSize; + other.archiveSize == archiveSize && + other.includeEmbeddedVideos == includeEmbeddedVideos; @override int get hashCode => // ignore: unnecessary_parenthesis - (archiveSize == null ? 0 : archiveSize!.hashCode); + (archiveSize == null ? 0 : archiveSize!.hashCode) + + (includeEmbeddedVideos == null ? 0 : includeEmbeddedVideos!.hashCode); @override - String toString() => 'DownloadUpdate[archiveSize=$archiveSize]'; + String toString() => 'DownloadUpdate[archiveSize=$archiveSize, includeEmbeddedVideos=$includeEmbeddedVideos]'; Map toJson() { final json = {}; @@ -44,6 +55,11 @@ class DownloadUpdate { } else { // json[r'archiveSize'] = null; } + if (this.includeEmbeddedVideos != null) { + json[r'includeEmbeddedVideos'] = this.includeEmbeddedVideos; + } else { + // json[r'includeEmbeddedVideos'] = null; + } return json; } @@ -51,11 +67,13 @@ class DownloadUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadUpdate? fromJson(dynamic value) { + upgradeDto(value, "DownloadUpdate"); if (value is Map) { final json = value.cast(); return DownloadUpdate( archiveSize: mapValueOfType(json, r'archiveSize'), + includeEmbeddedVideos: mapValueOfType(json, r'includeEmbeddedVideos'), ); } return null; diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart index 0bc6091784..e4fc352028 100644 --- a/mobile/openapi/lib/model/duplicate_detection_config.dart +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -48,6 +48,7 @@ class DuplicateDetectionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateDetectionConfig? fromJson(dynamic value) { + upgradeDto(value, "DuplicateDetectionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart index b93ecfe5f5..6ac7c46871 100644 --- a/mobile/openapi/lib/model/duplicate_response_dto.dart +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -46,6 +46,7 @@ class DuplicateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart index cef92957c6..d6dcfb9273 100644 --- a/mobile/openapi/lib/model/email_notifications_response.dart +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -52,6 +52,7 @@ class EmailNotificationsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsResponse? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart index dcd1ec4322..dad0a52fde 100644 --- a/mobile/openapi/lib/model/email_notifications_update.dart +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -82,6 +82,7 @@ class EmailNotificationsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsUpdate? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index d29d485a05..17397b2081 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -32,6 +32,7 @@ class ExifResponseDto { this.modifyDate, this.orientation, this.projectionType, + this.rating, this.state, this.timeZone, }); @@ -74,6 +75,8 @@ class ExifResponseDto { String? projectionType; + num? rating; + String? state; String? timeZone; @@ -99,6 +102,7 @@ class ExifResponseDto { other.modifyDate == modifyDate && other.orientation == orientation && other.projectionType == projectionType && + other.rating == rating && other.state == state && other.timeZone == timeZone; @@ -124,11 +128,12 @@ class ExifResponseDto { (modifyDate == null ? 0 : modifyDate!.hashCode) + (orientation == null ? 0 : orientation!.hashCode) + (projectionType == null ? 0 : projectionType!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (state == null ? 0 : state!.hashCode) + (timeZone == null ? 0 : timeZone!.hashCode); @override - String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, state=$state, timeZone=$timeZone]'; + String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; Map toJson() { final json = {}; @@ -227,6 +232,11 @@ class ExifResponseDto { } 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 { @@ -244,6 +254,7 @@ class ExifResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ExifResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ExifResponseDto"); if (value is Map) { final json = value.cast(); @@ -281,6 +292,9 @@ class ExifResponseDto { modifyDate: mapDateTime(json, r'modifyDate', r''), orientation: mapValueOfType(json, r'orientation'), projectionType: mapValueOfType(json, r'projectionType'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), state: mapValueOfType(json, r'state'), timeZone: mapValueOfType(json, r'timeZone'), ); diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4fcc86debf..c84a518b8c 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -40,6 +40,7 @@ class FaceDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FaceDto? fromJson(dynamic value) { + upgradeDto(value, "FaceDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 52400fd7e1..439efbbfae 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -22,14 +22,14 @@ class FacialRecognitionConfig { bool enabled; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 2 double maxDistance; /// Minimum value: 1 int minFaces; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 1 double minScore; @@ -69,6 +69,7 @@ class FacialRecognitionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FacialRecognitionConfig? fromJson(dynamic value) { + upgradeDto(value, "FacialRecognitionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index c7e8aa1da6..7dc9ccdf2f 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -40,6 +40,7 @@ class FileChecksumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index d4bae3c273..7b963c8bd5 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -46,6 +46,7 @@ class FileChecksumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 422215ff6c..3dc892e5e7 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -46,6 +46,7 @@ class FileReportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index cf09242b0f..d46cdeb4b7 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -40,6 +40,7 @@ class FileReportFixDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportFixDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportFixDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index 5255005daa..1ef08c2b48 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -74,6 +74,7 @@ class FileReportItemDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportItemDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportItemDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart new file mode 100644 index 0000000000..248b64b054 --- /dev/null +++ b/mobile/openapi/lib/model/folders_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 FoldersResponse { + /// Returns a new [FoldersResponse] instance. + FoldersResponse({ + this.enabled = false, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is FoldersResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'FoldersResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [FoldersResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FoldersResponse? fromJson(dynamic value) { + upgradeDto(value, "FoldersResponse"); + if (value is Map) { + final json = value.cast(); + + return FoldersResponse( + 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 = FoldersResponse.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 = FoldersResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FoldersResponse-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] = FoldersResponse.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/scan_library_dto.dart b/mobile/openapi/lib/model/folders_update.dart similarity index 55% rename from mobile/openapi/lib/model/scan_library_dto.dart rename to mobile/openapi/lib/model/folders_update.dart index 1b31aaaf01..0234717754 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/folders_update.dart @@ -10,11 +10,11 @@ part of openapi.api; -class ScanLibraryDto { - /// Returns a new [ScanLibraryDto] instance. - ScanLibraryDto({ - this.refreshAllFiles, - this.refreshModifiedFiles, +class FoldersUpdate { + /// Returns a new [FoldersUpdate] instance. + FoldersUpdate({ + this.enabled, + this.sidebarWeb, }); /// @@ -23,7 +23,7 @@ class ScanLibraryDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - bool? refreshAllFiles; + bool? enabled; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -31,57 +31,58 @@ class ScanLibraryDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - bool? refreshModifiedFiles; + bool? sidebarWeb; @override - bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && - other.refreshAllFiles == refreshAllFiles && - other.refreshModifiedFiles == refreshModifiedFiles; + bool operator ==(Object other) => identical(this, other) || other is FoldersUpdate && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; @override int get hashCode => // ignore: unnecessary_parenthesis - (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + - (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); + (enabled == null ? 0 : enabled!.hashCode) + + (sidebarWeb == null ? 0 : sidebarWeb!.hashCode); @override - String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; + String toString() => 'FoldersUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]'; Map toJson() { final json = {}; - if (this.refreshAllFiles != null) { - json[r'refreshAllFiles'] = this.refreshAllFiles; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; } else { - // json[r'refreshAllFiles'] = null; + // json[r'enabled'] = null; } - if (this.refreshModifiedFiles != null) { - json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; + if (this.sidebarWeb != null) { + json[r'sidebarWeb'] = this.sidebarWeb; } else { - // json[r'refreshModifiedFiles'] = null; + // json[r'sidebarWeb'] = null; } return json; } - /// Returns a new [ScanLibraryDto] instance and imports its values from + /// Returns a new [FoldersUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static ScanLibraryDto? fromJson(dynamic value) { + static FoldersUpdate? fromJson(dynamic value) { + upgradeDto(value, "FoldersUpdate"); if (value is Map) { final json = value.cast(); - return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), - refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), + return FoldersUpdate( + enabled: mapValueOfType(json, r'enabled'), + sidebarWeb: mapValueOfType(json, r'sidebarWeb'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = ScanLibraryDto.fromJson(row); + final value = FoldersUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -90,12 +91,12 @@ class ScanLibraryDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = ScanLibraryDto.fromJson(entry.value); + final value = FoldersUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -104,14 +105,14 @@ class ScanLibraryDto { return map; } - // maps a json object with a list of ScanLibraryDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of FoldersUpdate-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] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = FoldersUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 5c56715644..32274037f6 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -14,12 +14,18 @@ class JobCommandDto { /// Returns a new [JobCommandDto] instance. JobCommandDto({ required this.command, - required this.force, + this.force, }); JobCommand command; - bool force; + /// + /// 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? force; @override bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && @@ -30,7 +36,7 @@ class JobCommandDto { int get hashCode => // ignore: unnecessary_parenthesis (command.hashCode) + - (force.hashCode); + (force == null ? 0 : force!.hashCode); @override String toString() => 'JobCommandDto[command=$command, force=$force]'; @@ -38,7 +44,11 @@ class JobCommandDto { Map toJson() { final json = {}; json[r'command'] = this.command; + if (this.force != null) { json[r'force'] = this.force; + } else { + // json[r'force'] = null; + } return json; } @@ -46,12 +56,13 @@ class JobCommandDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCommandDto? fromJson(dynamic value) { + upgradeDto(value, "JobCommandDto"); if (value is Map) { final json = value.cast(); return JobCommandDto( command: JobCommand.fromJson(json[r'command'])!, - force: mapValueOfType(json, r'force')!, + force: mapValueOfType(json, r'force'), ); } return null; @@ -100,7 +111,6 @@ class JobCommandDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'command', - 'force', }; } diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index cf1d0b457d..afc90d1084 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -70,6 +70,7 @@ class JobCountsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCountsDto? fromJson(dynamic value) { + upgradeDto(value, "JobCountsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart similarity index 59% rename from mobile/openapi/lib/model/create_tag_dto.dart rename to mobile/openapi/lib/model/job_create_dto.dart index 31b194993d..fe6743cba0 100644 --- a/mobile/openapi/lib/model/create_tag_dto.dart +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -10,58 +10,52 @@ part of openapi.api; -class CreateTagDto { - /// Returns a new [CreateTagDto] instance. - CreateTagDto({ +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ required this.name, - required this.type, }); - String name; - - TagTypeEnum type; + ManualJobName name; @override - bool operator ==(Object other) => identical(this, other) || other is CreateTagDto && - other.name == name && - other.type == type; + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; @override int get hashCode => // ignore: unnecessary_parenthesis - (name.hashCode) + - (type.hashCode); + (name.hashCode); @override - String toString() => 'CreateTagDto[name=$name, type=$type]'; + String toString() => 'JobCreateDto[name=$name]'; Map toJson() { final json = {}; json[r'name'] = this.name; - json[r'type'] = this.type; return json; } - /// Returns a new [CreateTagDto] instance and imports its values from + /// Returns a new [JobCreateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static CreateTagDto? fromJson(dynamic value) { + static JobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "JobCreateDto"); if (value is Map) { final json = value.cast(); - return CreateTagDto( - name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = CreateTagDto.fromJson(row); + final value = JobCreateDto.fromJson(row); if (value != null) { result.add(value); } @@ -70,12 +64,12 @@ class CreateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = CreateTagDto.fromJson(entry.value); + final value = JobCreateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -84,14 +78,14 @@ class CreateTagDto { return map; } - // maps a json object with a list of CreateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of JobCreateDto-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] = CreateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -100,7 +94,6 @@ class CreateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'name', - 'type', }; } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 072da76d4c..6b9a002cbe 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -37,6 +37,7 @@ class JobName { static const sidecar = JobName._(r'sidecar'); static const library_ = JobName._(r'library'); static const notifications = JobName._(r'notifications'); + static const backupDatabase = JobName._(r'backupDatabase'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -54,6 +55,7 @@ class JobName { sidecar, library_, notifications, + backupDatabase, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -106,6 +108,7 @@ class JobNameTypeTransformer { case r'sidecar': return JobName.sidecar; case r'library': return JobName.library_; case r'notifications': return JobName.notifications; + case r'backupDatabase': return JobName.backupDatabase; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index 9c59d503ca..af354bef9e 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -41,6 +41,7 @@ class JobSettingsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "JobSettingsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index fd925bd53a..18fab8dfb3 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -46,6 +46,7 @@ class JobStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobStatusDto? fromJson(dynamic value) { + upgradeDto(value, "JobStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index e27b489104..3cf1248508 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -92,6 +92,7 @@ class LibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 8cfb292855..afe67da31a 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -58,6 +58,7 @@ class LibraryStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart index aece85f81e..d27d579bb4 100644 --- a/mobile/openapi/lib/model/license_key_dto.dart +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -46,6 +46,7 @@ class LicenseKeyDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseKeyDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseKeyDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart index f83668af57..6d3009433f 100644 --- a/mobile/openapi/lib/model/license_response_dto.dart +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -52,6 +52,7 @@ class LicenseResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index ac2f511691..7e892ab5fb 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -46,6 +46,7 @@ class LoginCredentialDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginCredentialDto? fromJson(dynamic value) { + upgradeDto(value, "LoginCredentialDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index 6a0eb2355c..dbc82d07ba 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -76,6 +76,7 @@ class LoginResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LoginResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index ca1e8d23bb..aa94904e2a 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -46,6 +46,7 @@ class LogoutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LogoutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LogoutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000..7e8d9d51b2 --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// 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 ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().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 = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// 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. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index ca1ec3c8a1..74ac51a271 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -82,6 +82,7 @@ class MapMarkerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapMarkerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapMarkerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart index ac99dd91a9..6d8757d39f 100644 --- a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -64,6 +64,7 @@ class MapReverseGeocodeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapReverseGeocodeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart new file mode 100644 index 0000000000..b9f8b5d8b1 --- /dev/null +++ b/mobile/openapi/lib/model/memories_response.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 MemoriesResponse { + /// Returns a new [MemoriesResponse] instance. + MemoriesResponse({ + this.enabled = true, + }); + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode); + + @override + String toString() => 'MemoriesResponse[enabled=$enabled]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + return json; + } + + /// Returns a new [MemoriesResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MemoriesResponse? fromJson(dynamic value) { + upgradeDto(value, "MemoriesResponse"); + if (value is Map) { + final json = value.cast(); + + return MemoriesResponse( + enabled: mapValueOfType(json, r'enabled')!, + ); + } + 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 = MemoriesResponse.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 = MemoriesResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MemoriesResponse-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] = MemoriesResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + }; +} + diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/memories_update.dart similarity index 59% rename from mobile/openapi/lib/model/update_tag_dto.dart rename to mobile/openapi/lib/model/memories_update.dart index dfa9b8cfc0..71efd71ae7 100644 --- a/mobile/openapi/lib/model/update_tag_dto.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -10,10 +10,10 @@ part of openapi.api; -class UpdateTagDto { - /// Returns a new [UpdateTagDto] instance. - UpdateTagDto({ - this.name, +class MemoriesUpdate { + /// Returns a new [MemoriesUpdate] instance. + MemoriesUpdate({ + this.enabled, }); /// @@ -22,49 +22,50 @@ class UpdateTagDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - String? name; + bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto && - other.name == name; + bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && + other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'UpdateTagDto[name=$name]'; + String toString() => 'MemoriesUpdate[enabled=$enabled]'; Map toJson() { final json = {}; - if (this.name != null) { - json[r'name'] = this.name; + if (this.enabled != null) { + json[r'enabled'] = this.enabled; } else { - // json[r'name'] = null; + // json[r'enabled'] = null; } return json; } - /// Returns a new [UpdateTagDto] instance and imports its values from + /// Returns a new [MemoriesUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateTagDto? fromJson(dynamic value) { + static MemoriesUpdate? fromJson(dynamic value) { + upgradeDto(value, "MemoriesUpdate"); if (value is Map) { final json = value.cast(); - return UpdateTagDto( - name: mapValueOfType(json, r'name'), + return MemoriesUpdate( + enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateTagDto.fromJson(row); + final value = MemoriesUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +74,12 @@ class UpdateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = UpdateTagDto.fromJson(entry.value); + final value = MemoriesUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +88,14 @@ class UpdateTagDto { return map; } - // maps a json object with a list of UpdateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MemoriesUpdate-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] = UpdateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = MemoriesUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 2efdf88936..15985f2f1c 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -90,6 +90,7 @@ class MemoryCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryCreateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 4abe607381..27248d05c1 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -46,6 +46,7 @@ class MemoryLaneResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryLaneResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryLaneResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index f794be53cd..652c993536 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -120,6 +120,7 @@ class MemoryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index 318f4b42ad..e750f9faad 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -82,6 +82,7 @@ class MemoryUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index ea23042e2c..fd225276b6 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -40,6 +40,7 @@ class MergePersonDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MergePersonDto? fromJson(dynamic value) { + upgradeDto(value, "MergePersonDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index d77f2e7736..0aef1f623e 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -64,20 +64,8 @@ class MetadataSearchDto { /// String? checksum; - /// - /// 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? city; - /// - /// 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? country; /// @@ -184,12 +172,6 @@ class MetadataSearchDto { /// bool? isVisible; - /// - /// 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? lensModel; String? libraryId; @@ -202,12 +184,6 @@ class MetadataSearchDto { /// String? make; - /// - /// 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? model; /// @@ -263,12 +239,6 @@ class MetadataSearchDto { /// num? size; - /// - /// 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? state; /// @@ -667,6 +637,7 @@ class MetadataSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MetadataSearchDto? fromJson(dynamic value) { + upgradeDto(value, "MetadataSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart index ffd017f816..869c3be753 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -40,6 +40,7 @@ class OAuthAuthorizeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthAuthorizeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 89ad0f60b0..d0b98d5c6f 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -40,6 +40,7 @@ class OAuthCallbackDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthCallbackDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthCallbackDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index 7d76758864..86c79b4e04 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -40,6 +40,7 @@ class OAuthConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthConfigDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index be170caf85..bfcc4fd630 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -41,6 +41,7 @@ class OnThisDayDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OnThisDayDto? fromJson(dynamic value) { + upgradeDto(value, "OnThisDayDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 7c3cf03bd9..f61df86b42 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -18,6 +18,7 @@ class PartnerResponseDto { required this.id, this.inTimeline, required this.name, + required this.profileChangedAt, required this.profileImagePath, }); @@ -37,6 +38,8 @@ class PartnerResponseDto { String name; + DateTime profileChangedAt; + String profileImagePath; @override @@ -46,6 +49,7 @@ class PartnerResponseDto { other.id == id && other.inTimeline == inTimeline && other.name == name && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath; @override @@ -56,10 +60,11 @@ class PartnerResponseDto { (id.hashCode) + (inTimeline == null ? 0 : inTimeline!.hashCode) + (name.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode); @override - String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileImagePath=$profileImagePath]'; + String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; @@ -72,6 +77,7 @@ class PartnerResponseDto { // json[r'inTimeline'] = null; } json[r'name'] = this.name; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -80,6 +86,7 @@ class PartnerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PartnerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PartnerResponseDto"); if (value is Map) { final json = value.cast(); @@ -89,6 +96,7 @@ class PartnerResponseDto { id: mapValueOfType(json, r'id')!, inTimeline: mapValueOfType(json, r'inTimeline'), name: mapValueOfType(json, r'name')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } @@ -141,6 +149,7 @@ class PartnerResponseDto { 'email', 'id', 'name', + 'profileChangedAt', 'profileImagePath', }; } diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart new file mode 100644 index 0000000000..1312c73874 --- /dev/null +++ b/mobile/openapi/lib/model/people_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 PeopleResponse { + /// Returns a new [PeopleResponse] instance. + PeopleResponse({ + this.enabled = true, + this.sidebarWeb = false, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [PeopleResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleResponse? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponse"); + if (value is Map) { + final json = value.cast(); + + return PeopleResponse( + 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 = PeopleResponse.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 = PeopleResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleResponse-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] = PeopleResponse.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/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 87e8c34fb0..49f0e85aad 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -69,6 +69,7 @@ class PeopleResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart new file mode 100644 index 0000000000..fb4eeeb434 --- /dev/null +++ b/mobile/openapi/lib/model/people_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 PeopleUpdate { + /// Returns a new [PeopleUpdate] instance. + PeopleUpdate({ + 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 PeopleUpdate && + 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() => 'PeopleUpdate[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 [PeopleUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleUpdate? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdate"); + if (value is Map) { + final json = value.cast(); + + return PeopleUpdate( + 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 = PeopleUpdate.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 = PeopleUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleUpdate-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] = PeopleUpdate.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/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index 9fcfdc8761..f771084f75 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -40,6 +40,7 @@ class PeopleUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 8af0a8b11a..042e4fa36f 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -103,6 +103,7 @@ class PeopleUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart new file mode 100644 index 0000000000..1244a434b6 --- /dev/null +++ b/mobile/openapi/lib/model/permission.dart @@ -0,0 +1,313 @@ +// +// 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 Permission { + /// Instantiate a new enum with the provided [value]. + const Permission._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = Permission._(r'all'); + static const activityPeriodCreate = Permission._(r'activity.create'); + static const activityPeriodRead = Permission._(r'activity.read'); + static const activityPeriodUpdate = Permission._(r'activity.update'); + static const activityPeriodDelete = Permission._(r'activity.delete'); + static const activityPeriodStatistics = Permission._(r'activity.statistics'); + static const apiKeyPeriodCreate = Permission._(r'apiKey.create'); + static const apiKeyPeriodRead = Permission._(r'apiKey.read'); + static const apiKeyPeriodUpdate = Permission._(r'apiKey.update'); + static const apiKeyPeriodDelete = Permission._(r'apiKey.delete'); + static const assetPeriodRead = Permission._(r'asset.read'); + static const assetPeriodUpdate = Permission._(r'asset.update'); + static const assetPeriodDelete = Permission._(r'asset.delete'); + static const assetPeriodShare = Permission._(r'asset.share'); + static const assetPeriodView = Permission._(r'asset.view'); + static const assetPeriodDownload = Permission._(r'asset.download'); + static const assetPeriodUpload = Permission._(r'asset.upload'); + static const albumPeriodCreate = Permission._(r'album.create'); + static const albumPeriodRead = Permission._(r'album.read'); + static const albumPeriodUpdate = Permission._(r'album.update'); + static const albumPeriodDelete = Permission._(r'album.delete'); + static const albumPeriodStatistics = Permission._(r'album.statistics'); + static const albumPeriodAddAsset = Permission._(r'album.addAsset'); + static const albumPeriodRemoveAsset = Permission._(r'album.removeAsset'); + static const albumPeriodShare = Permission._(r'album.share'); + static const albumPeriodDownload = Permission._(r'album.download'); + static const authDevicePeriodDelete = Permission._(r'authDevice.delete'); + static const archivePeriodRead = Permission._(r'archive.read'); + static const facePeriodCreate = Permission._(r'face.create'); + static const facePeriodRead = Permission._(r'face.read'); + static const facePeriodUpdate = Permission._(r'face.update'); + static const facePeriodDelete = Permission._(r'face.delete'); + static const libraryPeriodCreate = Permission._(r'library.create'); + static const libraryPeriodRead = Permission._(r'library.read'); + static const libraryPeriodUpdate = Permission._(r'library.update'); + static const libraryPeriodDelete = Permission._(r'library.delete'); + static const libraryPeriodStatistics = Permission._(r'library.statistics'); + static const timelinePeriodRead = Permission._(r'timeline.read'); + static const timelinePeriodDownload = Permission._(r'timeline.download'); + static const memoryPeriodCreate = Permission._(r'memory.create'); + static const memoryPeriodRead = Permission._(r'memory.read'); + static const memoryPeriodUpdate = Permission._(r'memory.update'); + static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const partnerPeriodCreate = Permission._(r'partner.create'); + static const partnerPeriodRead = Permission._(r'partner.read'); + static const partnerPeriodUpdate = Permission._(r'partner.update'); + static const partnerPeriodDelete = Permission._(r'partner.delete'); + static const personPeriodCreate = Permission._(r'person.create'); + static const personPeriodRead = Permission._(r'person.read'); + static const personPeriodUpdate = Permission._(r'person.update'); + static const personPeriodDelete = Permission._(r'person.delete'); + static const personPeriodStatistics = Permission._(r'person.statistics'); + static const personPeriodMerge = Permission._(r'person.merge'); + static const personPeriodReassign = Permission._(r'person.reassign'); + static const sessionPeriodRead = Permission._(r'session.read'); + static const sessionPeriodUpdate = Permission._(r'session.update'); + static const sessionPeriodDelete = Permission._(r'session.delete'); + static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); + static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); + static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); + static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete'); + static const stackPeriodCreate = Permission._(r'stack.create'); + static const stackPeriodRead = Permission._(r'stack.read'); + static const stackPeriodUpdate = Permission._(r'stack.update'); + static const stackPeriodDelete = Permission._(r'stack.delete'); + static const systemConfigPeriodRead = Permission._(r'systemConfig.read'); + static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update'); + static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read'); + static const systemMetadataPeriodUpdate = Permission._(r'systemMetadata.update'); + static const tagPeriodCreate = Permission._(r'tag.create'); + static const tagPeriodRead = Permission._(r'tag.read'); + static const tagPeriodUpdate = Permission._(r'tag.update'); + static const tagPeriodDelete = Permission._(r'tag.delete'); + static const tagPeriodAsset = Permission._(r'tag.asset'); + static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); + static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); + static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); + static const adminPeriodUserPeriodDelete = Permission._(r'admin.user.delete'); + + /// List of all possible values in this [enum][Permission]. + static const values = [ + all, + activityPeriodCreate, + activityPeriodRead, + activityPeriodUpdate, + activityPeriodDelete, + activityPeriodStatistics, + apiKeyPeriodCreate, + apiKeyPeriodRead, + apiKeyPeriodUpdate, + apiKeyPeriodDelete, + assetPeriodRead, + assetPeriodUpdate, + assetPeriodDelete, + assetPeriodShare, + assetPeriodView, + assetPeriodDownload, + assetPeriodUpload, + albumPeriodCreate, + albumPeriodRead, + albumPeriodUpdate, + albumPeriodDelete, + albumPeriodStatistics, + albumPeriodAddAsset, + albumPeriodRemoveAsset, + albumPeriodShare, + albumPeriodDownload, + authDevicePeriodDelete, + archivePeriodRead, + facePeriodCreate, + facePeriodRead, + facePeriodUpdate, + facePeriodDelete, + libraryPeriodCreate, + libraryPeriodRead, + libraryPeriodUpdate, + libraryPeriodDelete, + libraryPeriodStatistics, + timelinePeriodRead, + timelinePeriodDownload, + memoryPeriodCreate, + memoryPeriodRead, + memoryPeriodUpdate, + memoryPeriodDelete, + partnerPeriodCreate, + partnerPeriodRead, + partnerPeriodUpdate, + partnerPeriodDelete, + personPeriodCreate, + personPeriodRead, + personPeriodUpdate, + personPeriodDelete, + personPeriodStatistics, + personPeriodMerge, + personPeriodReassign, + sessionPeriodRead, + sessionPeriodUpdate, + sessionPeriodDelete, + sharedLinkPeriodCreate, + sharedLinkPeriodRead, + sharedLinkPeriodUpdate, + sharedLinkPeriodDelete, + stackPeriodCreate, + stackPeriodRead, + stackPeriodUpdate, + stackPeriodDelete, + systemConfigPeriodRead, + systemConfigPeriodUpdate, + systemMetadataPeriodRead, + systemMetadataPeriodUpdate, + tagPeriodCreate, + tagPeriodRead, + tagPeriodUpdate, + tagPeriodDelete, + tagPeriodAsset, + adminPeriodUserPeriodCreate, + adminPeriodUserPeriodRead, + adminPeriodUserPeriodUpdate, + adminPeriodUserPeriodDelete, + ]; + + static Permission? fromJson(dynamic value) => PermissionTypeTransformer().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 = Permission.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [Permission] to String, +/// and [decode] dynamic data back to [Permission]. +class PermissionTypeTransformer { + factory PermissionTypeTransformer() => _instance ??= const PermissionTypeTransformer._(); + + const PermissionTypeTransformer._(); + + String encode(Permission data) => data.value; + + /// Decodes a [dynamic value][data] to a Permission. + /// + /// 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. + Permission? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return Permission.all; + case r'activity.create': return Permission.activityPeriodCreate; + case r'activity.read': return Permission.activityPeriodRead; + case r'activity.update': return Permission.activityPeriodUpdate; + case r'activity.delete': return Permission.activityPeriodDelete; + case r'activity.statistics': return Permission.activityPeriodStatistics; + case r'apiKey.create': return Permission.apiKeyPeriodCreate; + case r'apiKey.read': return Permission.apiKeyPeriodRead; + case r'apiKey.update': return Permission.apiKeyPeriodUpdate; + case r'apiKey.delete': return Permission.apiKeyPeriodDelete; + case r'asset.read': return Permission.assetPeriodRead; + case r'asset.update': return Permission.assetPeriodUpdate; + case r'asset.delete': return Permission.assetPeriodDelete; + case r'asset.share': return Permission.assetPeriodShare; + case r'asset.view': return Permission.assetPeriodView; + case r'asset.download': return Permission.assetPeriodDownload; + case r'asset.upload': return Permission.assetPeriodUpload; + case r'album.create': return Permission.albumPeriodCreate; + case r'album.read': return Permission.albumPeriodRead; + case r'album.update': return Permission.albumPeriodUpdate; + case r'album.delete': return Permission.albumPeriodDelete; + case r'album.statistics': return Permission.albumPeriodStatistics; + case r'album.addAsset': return Permission.albumPeriodAddAsset; + case r'album.removeAsset': return Permission.albumPeriodRemoveAsset; + case r'album.share': return Permission.albumPeriodShare; + case r'album.download': return Permission.albumPeriodDownload; + case r'authDevice.delete': return Permission.authDevicePeriodDelete; + case r'archive.read': return Permission.archivePeriodRead; + case r'face.create': return Permission.facePeriodCreate; + case r'face.read': return Permission.facePeriodRead; + case r'face.update': return Permission.facePeriodUpdate; + case r'face.delete': return Permission.facePeriodDelete; + case r'library.create': return Permission.libraryPeriodCreate; + case r'library.read': return Permission.libraryPeriodRead; + case r'library.update': return Permission.libraryPeriodUpdate; + case r'library.delete': return Permission.libraryPeriodDelete; + case r'library.statistics': return Permission.libraryPeriodStatistics; + case r'timeline.read': return Permission.timelinePeriodRead; + case r'timeline.download': return Permission.timelinePeriodDownload; + case r'memory.create': return Permission.memoryPeriodCreate; + case r'memory.read': return Permission.memoryPeriodRead; + case r'memory.update': return Permission.memoryPeriodUpdate; + case r'memory.delete': return Permission.memoryPeriodDelete; + case r'partner.create': return Permission.partnerPeriodCreate; + case r'partner.read': return Permission.partnerPeriodRead; + case r'partner.update': return Permission.partnerPeriodUpdate; + case r'partner.delete': return Permission.partnerPeriodDelete; + case r'person.create': return Permission.personPeriodCreate; + case r'person.read': return Permission.personPeriodRead; + case r'person.update': return Permission.personPeriodUpdate; + case r'person.delete': return Permission.personPeriodDelete; + case r'person.statistics': return Permission.personPeriodStatistics; + case r'person.merge': return Permission.personPeriodMerge; + case r'person.reassign': return Permission.personPeriodReassign; + case r'session.read': return Permission.sessionPeriodRead; + case r'session.update': return Permission.sessionPeriodUpdate; + case r'session.delete': return Permission.sessionPeriodDelete; + case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; + case r'sharedLink.read': return Permission.sharedLinkPeriodRead; + case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; + case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete; + case r'stack.create': return Permission.stackPeriodCreate; + case r'stack.read': return Permission.stackPeriodRead; + case r'stack.update': return Permission.stackPeriodUpdate; + case r'stack.delete': return Permission.stackPeriodDelete; + case r'systemConfig.read': return Permission.systemConfigPeriodRead; + case r'systemConfig.update': return Permission.systemConfigPeriodUpdate; + case r'systemMetadata.read': return Permission.systemMetadataPeriodRead; + case r'systemMetadata.update': return Permission.systemMetadataPeriodUpdate; + case r'tag.create': return Permission.tagPeriodCreate; + case r'tag.read': return Permission.tagPeriodRead; + case r'tag.update': return Permission.tagPeriodUpdate; + case r'tag.delete': return Permission.tagPeriodDelete; + case r'tag.asset': return Permission.tagPeriodAsset; + case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; + case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; + case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; + case r'admin.user.delete': return Permission.adminPeriodUserPeriodDelete; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PermissionTypeTransformer] instance. + static PermissionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 9889328dee..36bd6dfee9 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -79,6 +79,7 @@ class PersonCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonCreateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 50ee28f0af..0b36fcde3b 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -85,6 +85,7 @@ class PersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 929fbc29d2..d9f84e9f4c 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -40,6 +40,7 @@ class PersonStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 1af03890a2..51a7ea25d0 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -96,6 +96,7 @@ class PersonUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonUpdateDto"); if (value is Map) { final json = value.cast(); 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 af2e7101c3..b14bad7895 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -91,6 +91,7 @@ class PersonWithFacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonWithFacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonWithFacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index d3e1fc449b..4f77788263 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -84,6 +84,7 @@ class PlacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PlacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PlacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_response.dart b/mobile/openapi/lib/model/purchase_response.dart index 284d899528..a117206977 100644 --- a/mobile/openapi/lib/model/purchase_response.dart +++ b/mobile/openapi/lib/model/purchase_response.dart @@ -46,6 +46,7 @@ class PurchaseResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseResponse? fromJson(dynamic value) { + upgradeDto(value, "PurchaseResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_update.dart b/mobile/openapi/lib/model/purchase_update.dart index ca0a27e3bc..69057e6c55 100644 --- a/mobile/openapi/lib/model/purchase_update.dart +++ b/mobile/openapi/lib/model/purchase_update.dart @@ -66,6 +66,7 @@ class PurchaseUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseUpdate? fromJson(dynamic value) { + upgradeDto(value, "PurchaseUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 7f7d310f6f..77591affe2 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -46,6 +46,7 @@ class QueueStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static QueueStatusDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart new file mode 100644 index 0000000000..3fcab05bbb --- /dev/null +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -0,0 +1,566 @@ +// +// 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 RandomSearchDto { + /// Returns a new [RandomSearchDto] instance. + RandomSearchDto({ + this.city, + this.country, + this.createdAfter, + this.createdBefore, + this.deviceId, + this.isArchived, + this.isEncoded, + this.isFavorite, + this.isMotion, + this.isNotInAlbum, + this.isOffline, + this.isVisible, + this.lensModel, + this.libraryId, + this.make, + this.model, + this.personIds = const [], + this.size, + this.state, + this.takenAfter, + this.takenBefore, + this.trashedAfter, + this.trashedBefore, + this.type, + this.updatedAfter, + this.updatedBefore, + this.withArchived = false, + this.withDeleted, + this.withExif, + this.withPeople, + this.withStacked, + }); + + String? city; + + String? country; + + /// + /// 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? createdAfter; + + /// + /// 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? 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? deviceId; + + /// + /// 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? isArchived; + + /// + /// 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? isEncoded; + + /// + /// 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; + + /// + /// 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? isMotion; + + /// + /// 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? isNotInAlbum; + + /// + /// 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? isOffline; + + /// + /// 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? isVisible; + + String? lensModel; + + String? libraryId; + + /// + /// 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? make; + + String? model; + + List personIds; + + /// Minimum value: 1 + /// Maximum value: 1000 + /// + /// 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? size; + + String? state; + + /// + /// 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? takenAfter; + + /// + /// 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? takenBefore; + + /// + /// 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? trashedAfter; + + /// + /// 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? trashedBefore; + + /// + /// 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. + /// + AssetTypeEnum? type; + + /// + /// 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? updatedAfter; + + /// + /// 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? updatedBefore; + + bool withArchived; + + /// + /// 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? withDeleted; + + /// + /// 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? withExif; + + /// + /// 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? withPeople; + + /// + /// 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? withStacked; + + @override + bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + other.city == city && + other.country == country && + other.createdAfter == createdAfter && + other.createdBefore == createdBefore && + other.deviceId == deviceId && + other.isArchived == isArchived && + other.isEncoded == isEncoded && + other.isFavorite == isFavorite && + other.isMotion == isMotion && + other.isNotInAlbum == isNotInAlbum && + other.isOffline == isOffline && + other.isVisible == isVisible && + other.lensModel == lensModel && + other.libraryId == libraryId && + other.make == make && + other.model == model && + _deepEquality.equals(other.personIds, personIds) && + other.size == size && + other.state == state && + other.takenAfter == takenAfter && + other.takenBefore == takenBefore && + other.trashedAfter == trashedAfter && + other.trashedBefore == trashedBefore && + other.type == type && + other.updatedAfter == updatedAfter && + other.updatedBefore == updatedBefore && + other.withArchived == withArchived && + other.withDeleted == withDeleted && + other.withExif == withExif && + other.withPeople == withPeople && + other.withStacked == withStacked; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (createdAfter == null ? 0 : createdAfter!.hashCode) + + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (deviceId == null ? 0 : deviceId!.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isEncoded == null ? 0 : isEncoded!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (isMotion == null ? 0 : isMotion!.hashCode) + + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (personIds.hashCode) + + (size == null ? 0 : size!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (takenAfter == null ? 0 : takenAfter!.hashCode) + + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + + (type == null ? 0 : type!.hashCode) + + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + + (withArchived.hashCode) + + (withDeleted == null ? 0 : withDeleted!.hashCode) + + (withExif == null ? 0 : withExif!.hashCode) + + (withPeople == null ? 0 : withPeople!.hashCode) + + (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]'; + + Map toJson() { + final json = {}; + 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.createdAfter != null) { + json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String(); + } else { + // json[r'createdAfter'] = null; + } + if (this.createdBefore != null) { + json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String(); + } else { + // json[r'createdBefore'] = null; + } + if (this.deviceId != null) { + json[r'deviceId'] = this.deviceId; + } else { + // json[r'deviceId'] = null; + } + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isEncoded != null) { + json[r'isEncoded'] = this.isEncoded; + } else { + // json[r'isEncoded'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + if (this.isMotion != null) { + json[r'isMotion'] = this.isMotion; + } else { + // json[r'isMotion'] = null; + } + if (this.isNotInAlbum != null) { + json[r'isNotInAlbum'] = this.isNotInAlbum; + } else { + // json[r'isNotInAlbum'] = null; + } + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = 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; + } + json[r'personIds'] = this.personIds; + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.takenAfter != null) { + json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); + } else { + // json[r'takenAfter'] = null; + } + if (this.takenBefore != null) { + json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String(); + } else { + // json[r'takenBefore'] = null; + } + if (this.trashedAfter != null) { + json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String(); + } else { + // json[r'trashedAfter'] = null; + } + if (this.trashedBefore != null) { + json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String(); + } else { + // json[r'trashedBefore'] = null; + } + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + if (this.updatedAfter != null) { + json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String(); + } else { + // json[r'updatedAfter'] = null; + } + if (this.updatedBefore != null) { + json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); + } else { + // json[r'updatedBefore'] = null; + } + json[r'withArchived'] = this.withArchived; + if (this.withDeleted != null) { + json[r'withDeleted'] = this.withDeleted; + } else { + // json[r'withDeleted'] = null; + } + if (this.withExif != null) { + json[r'withExif'] = this.withExif; + } else { + // json[r'withExif'] = null; + } + if (this.withPeople != null) { + json[r'withPeople'] = this.withPeople; + } else { + // json[r'withPeople'] = null; + } + if (this.withStacked != null) { + json[r'withStacked'] = this.withStacked; + } else { + // json[r'withStacked'] = null; + } + return json; + } + + /// Returns a new [RandomSearchDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RandomSearchDto? fromJson(dynamic value) { + upgradeDto(value, "RandomSearchDto"); + if (value is Map) { + final json = value.cast(); + + return RandomSearchDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + createdAfter: mapDateTime(json, r'createdAfter', r''), + createdBefore: mapDateTime(json, r'createdBefore', r''), + deviceId: mapValueOfType(json, r'deviceId'), + isArchived: mapValueOfType(json, r'isArchived'), + isEncoded: mapValueOfType(json, r'isEncoded'), + isFavorite: mapValueOfType(json, r'isFavorite'), + isMotion: mapValueOfType(json, r'isMotion'), + isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'), + isOffline: mapValueOfType(json, r'isOffline'), + isVisible: mapValueOfType(json, r'isVisible'), + lensModel: mapValueOfType(json, r'lensModel'), + libraryId: mapValueOfType(json, r'libraryId'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + personIds: json[r'personIds'] is Iterable + ? (json[r'personIds'] as Iterable).cast().toList(growable: false) + : const [], + size: num.parse('${json[r'size']}'), + state: mapValueOfType(json, r'state'), + takenAfter: mapDateTime(json, r'takenAfter', r''), + takenBefore: mapDateTime(json, r'takenBefore', r''), + trashedAfter: mapDateTime(json, r'trashedAfter', r''), + trashedBefore: mapDateTime(json, r'trashedBefore', r''), + type: AssetTypeEnum.fromJson(json[r'type']), + updatedAfter: mapDateTime(json, r'updatedAfter', r''), + updatedBefore: mapDateTime(json, r'updatedBefore', r''), + withArchived: mapValueOfType(json, r'withArchived') ?? false, + withDeleted: mapValueOfType(json, r'withDeleted'), + withExif: mapValueOfType(json, r'withExif'), + withPeople: mapValueOfType(json, r'withPeople'), + withStacked: mapValueOfType(json, r'withStacked'), + ); + } + 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 = RandomSearchDto.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 = RandomSearchDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RandomSearchDto-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] = RandomSearchDto.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/memory_response.dart b/mobile/openapi/lib/model/ratings_response.dart similarity index 61% rename from mobile/openapi/lib/model/memory_response.dart rename to mobile/openapi/lib/model/ratings_response.dart index fb34bc1518..8e1951277a 100644 --- a/mobile/openapi/lib/model/memory_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -10,16 +10,16 @@ part of openapi.api; -class MemoryResponse { - /// Returns a new [MemoryResponse] instance. - MemoryResponse({ - required this.enabled, +class RatingsResponse { + /// Returns a new [RatingsResponse] instance. + RatingsResponse({ + this.enabled = false, }); bool enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryResponse && + bool operator ==(Object other) => identical(this, other) || other is RatingsResponse && other.enabled == enabled; @override @@ -28,7 +28,7 @@ class MemoryResponse { (enabled.hashCode); @override - String toString() => 'MemoryResponse[enabled=$enabled]'; + String toString() => 'RatingsResponse[enabled=$enabled]'; Map toJson() { final json = {}; @@ -36,25 +36,26 @@ class MemoryResponse { return json; } - /// Returns a new [MemoryResponse] instance and imports its values from + /// Returns a new [RatingsResponse] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryResponse? fromJson(dynamic value) { + static RatingsResponse? fromJson(dynamic value) { + upgradeDto(value, "RatingsResponse"); if (value is Map) { final json = value.cast(); - return MemoryResponse( + return RatingsResponse( enabled: mapValueOfType(json, r'enabled')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryResponse.fromJson(row); + final value = RatingsResponse.fromJson(row); if (value != null) { result.add(value); } @@ -63,12 +64,12 @@ class MemoryResponse { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = MemoryResponse.fromJson(entry.value); + final value = RatingsResponse.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -77,14 +78,14 @@ class MemoryResponse { return map; } - // maps a json object with a list of MemoryResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsResponse-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] = MemoryResponse.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsResponse.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/memory_update.dart b/mobile/openapi/lib/model/ratings_update.dart similarity index 68% rename from mobile/openapi/lib/model/memory_update.dart rename to mobile/openapi/lib/model/ratings_update.dart index f2529186c0..5d9f9a655f 100644 --- a/mobile/openapi/lib/model/memory_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -10,9 +10,9 @@ part of openapi.api; -class MemoryUpdate { - /// Returns a new [MemoryUpdate] instance. - MemoryUpdate({ +class RatingsUpdate { + /// Returns a new [RatingsUpdate] instance. + RatingsUpdate({ this.enabled, }); @@ -25,7 +25,7 @@ class MemoryUpdate { bool? enabled; @override - bool operator ==(Object other) => identical(this, other) || other is MemoryUpdate && + bool operator ==(Object other) => identical(this, other) || other is RatingsUpdate && other.enabled == enabled; @override @@ -34,7 +34,7 @@ class MemoryUpdate { (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoryUpdate[enabled=$enabled]'; + String toString() => 'RatingsUpdate[enabled=$enabled]'; Map toJson() { final json = {}; @@ -46,25 +46,26 @@ class MemoryUpdate { return json; } - /// Returns a new [MemoryUpdate] instance and imports its values from + /// Returns a new [RatingsUpdate] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MemoryUpdate? fromJson(dynamic value) { + static RatingsUpdate? fromJson(dynamic value) { + upgradeDto(value, "RatingsUpdate"); if (value is Map) { final json = value.cast(); - return MemoryUpdate( + return RatingsUpdate( enabled: mapValueOfType(json, r'enabled'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MemoryUpdate.fromJson(row); + final value = RatingsUpdate.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +74,12 @@ class MemoryUpdate { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = MemoryUpdate.fromJson(entry.value); + final value = RatingsUpdate.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +88,14 @@ class MemoryUpdate { return map; } - // maps a json object with a list of MemoryUpdate-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of RatingsUpdate-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] = MemoryUpdate.listFromJson(entry.value, growable: growable,); + map[entry.key] = RatingsUpdate.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart index eb414be984..5b3648b46b 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -54,6 +54,7 @@ class ReverseGeocodingStateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ReverseGeocodingStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ReverseGeocodingStateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 46ce5273ac..e9b47e85ec 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -58,6 +58,7 @@ class SearchAlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 21ddbbb213..3d214e61d9 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -68,6 +68,7 @@ class SearchAssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 951fdd1bc8..d44b2cd704 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -46,6 +46,7 @@ class SearchExploreItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreItem? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index 5bc601de9e..3b5d4f9849 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -46,6 +46,7 @@ class SearchExploreResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart index b40710e525..f8eee84485 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetCountResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetCountResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetCountResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 0784921c6b..aeec873c8d 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 9b2b7fd3cf..ca742ae35c 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -46,6 +46,7 @@ class SearchResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 9c71d1fccd..5d53d5fdee 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -28,6 +28,10 @@ class ServerAboutResponseDto { this.sourceCommit, this.sourceRef, this.sourceUrl, + this.thirdPartyBugFeatureUrl, + this.thirdPartyDocumentationUrl, + this.thirdPartySourceUrl, + this.thirdPartySupportUrl, required this.version, required this.versionUrl, }); @@ -146,6 +150,38 @@ class ServerAboutResponseDto { /// String? sourceUrl; + /// + /// 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? thirdPartyBugFeatureUrl; + + /// + /// 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? thirdPartyDocumentationUrl; + + /// + /// 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? thirdPartySourceUrl; + + /// + /// 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? thirdPartySupportUrl; + String version; String versionUrl; @@ -167,6 +203,10 @@ class ServerAboutResponseDto { other.sourceCommit == sourceCommit && other.sourceRef == sourceRef && other.sourceUrl == sourceUrl && + other.thirdPartyBugFeatureUrl == thirdPartyBugFeatureUrl && + other.thirdPartyDocumentationUrl == thirdPartyDocumentationUrl && + other.thirdPartySourceUrl == thirdPartySourceUrl && + other.thirdPartySupportUrl == thirdPartySupportUrl && other.version == version && other.versionUrl == versionUrl; @@ -188,11 +228,15 @@ class ServerAboutResponseDto { (sourceCommit == null ? 0 : sourceCommit!.hashCode) + (sourceRef == null ? 0 : sourceRef!.hashCode) + (sourceUrl == null ? 0 : sourceUrl!.hashCode) + + (thirdPartyBugFeatureUrl == null ? 0 : thirdPartyBugFeatureUrl!.hashCode) + + (thirdPartyDocumentationUrl == null ? 0 : thirdPartyDocumentationUrl!.hashCode) + + (thirdPartySourceUrl == null ? 0 : thirdPartySourceUrl!.hashCode) + + (thirdPartySupportUrl == null ? 0 : thirdPartySupportUrl!.hashCode) + (version.hashCode) + (versionUrl.hashCode); @override - String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, licensed=$licensed, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]'; + String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, licensed=$licensed, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, thirdPartyBugFeatureUrl=$thirdPartyBugFeatureUrl, thirdPartyDocumentationUrl=$thirdPartyDocumentationUrl, thirdPartySourceUrl=$thirdPartySourceUrl, thirdPartySupportUrl=$thirdPartySupportUrl, version=$version, versionUrl=$versionUrl]'; Map toJson() { final json = {}; @@ -266,6 +310,26 @@ class ServerAboutResponseDto { json[r'sourceUrl'] = this.sourceUrl; } else { // json[r'sourceUrl'] = null; + } + if (this.thirdPartyBugFeatureUrl != null) { + json[r'thirdPartyBugFeatureUrl'] = this.thirdPartyBugFeatureUrl; + } else { + // json[r'thirdPartyBugFeatureUrl'] = null; + } + if (this.thirdPartyDocumentationUrl != null) { + json[r'thirdPartyDocumentationUrl'] = this.thirdPartyDocumentationUrl; + } else { + // json[r'thirdPartyDocumentationUrl'] = null; + } + if (this.thirdPartySourceUrl != null) { + json[r'thirdPartySourceUrl'] = this.thirdPartySourceUrl; + } else { + // json[r'thirdPartySourceUrl'] = null; + } + if (this.thirdPartySupportUrl != null) { + json[r'thirdPartySupportUrl'] = this.thirdPartySupportUrl; + } else { + // json[r'thirdPartySupportUrl'] = null; } json[r'version'] = this.version; json[r'versionUrl'] = this.versionUrl; @@ -276,6 +340,7 @@ class ServerAboutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerAboutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerAboutResponseDto"); if (value is Map) { final json = value.cast(); @@ -295,6 +360,10 @@ class ServerAboutResponseDto { sourceCommit: mapValueOfType(json, r'sourceCommit'), sourceRef: mapValueOfType(json, r'sourceRef'), sourceUrl: mapValueOfType(json, r'sourceUrl'), + thirdPartyBugFeatureUrl: mapValueOfType(json, r'thirdPartyBugFeatureUrl'), + thirdPartyDocumentationUrl: mapValueOfType(json, r'thirdPartyDocumentationUrl'), + thirdPartySourceUrl: mapValueOfType(json, r'thirdPartySourceUrl'), + thirdPartySupportUrl: mapValueOfType(json, r'thirdPartySupportUrl'), version: mapValueOfType(json, r'version')!, versionUrl: mapValueOfType(json, r'versionUrl')!, ); diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 47cc52fb2c..bd5c2405e2 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -17,6 +17,8 @@ class ServerConfigDto { required this.isInitialized, required this.isOnboarded, required this.loginPageMessage, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, required this.oauthButtonText, required this.trashDays, required this.userDeleteDelay, @@ -30,6 +32,10 @@ class ServerConfigDto { String loginPageMessage; + String mapDarkStyleUrl; + + String mapLightStyleUrl; + String oauthButtonText; int trashDays; @@ -42,6 +48,8 @@ class ServerConfigDto { other.isInitialized == isInitialized && other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && + other.mapDarkStyleUrl == mapDarkStyleUrl && + other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -53,12 +61,14 @@ class ServerConfigDto { (isInitialized.hashCode) + (isOnboarded.hashCode) + (loginPageMessage.hashCode) + + (mapDarkStyleUrl.hashCode) + + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -66,6 +76,8 @@ class ServerConfigDto { json[r'isInitialized'] = this.isInitialized; json[r'isOnboarded'] = this.isOnboarded; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; + json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; @@ -76,6 +88,7 @@ class ServerConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerConfigDto? fromJson(dynamic value) { + upgradeDto(value, "ServerConfigDto"); if (value is Map) { final json = value.cast(); @@ -84,6 +97,8 @@ class ServerConfigDto { isInitialized: mapValueOfType(json, r'isInitialized')!, isOnboarded: mapValueOfType(json, r'isOnboarded')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, + mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, @@ -138,6 +153,8 @@ class ServerConfigDto { 'isInitialized', 'isOnboarded', 'loginPageMessage', + 'mapDarkStyleUrl', + 'mapLightStyleUrl', 'oauthButtonText', 'trashDays', 'userDeleteDelay', diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 3e5466237a..5149c3796a 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -17,6 +17,7 @@ class ServerFeaturesDto { required this.duplicateDetection, required this.email, required this.facialRecognition, + required this.importFaces, required this.map, required this.oauth, required this.oauthAutoLaunch, @@ -36,6 +37,8 @@ class ServerFeaturesDto { bool facialRecognition; + bool importFaces; + bool map; bool oauth; @@ -60,6 +63,7 @@ class ServerFeaturesDto { other.duplicateDetection == duplicateDetection && other.email == email && other.facialRecognition == facialRecognition && + other.importFaces == importFaces && other.map == map && other.oauth == oauth && other.oauthAutoLaunch == oauthAutoLaunch && @@ -77,6 +81,7 @@ class ServerFeaturesDto { (duplicateDetection.hashCode) + (email.hashCode) + (facialRecognition.hashCode) + + (importFaces.hashCode) + (map.hashCode) + (oauth.hashCode) + (oauthAutoLaunch.hashCode) + @@ -88,7 +93,7 @@ class ServerFeaturesDto { (trash.hashCode); @override - String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; + String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; Map toJson() { final json = {}; @@ -96,6 +101,7 @@ class ServerFeaturesDto { json[r'duplicateDetection'] = this.duplicateDetection; json[r'email'] = this.email; json[r'facialRecognition'] = this.facialRecognition; + json[r'importFaces'] = this.importFaces; json[r'map'] = this.map; json[r'oauth'] = this.oauth; json[r'oauthAutoLaunch'] = this.oauthAutoLaunch; @@ -112,6 +118,7 @@ class ServerFeaturesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerFeaturesDto? fromJson(dynamic value) { + upgradeDto(value, "ServerFeaturesDto"); if (value is Map) { final json = value.cast(); @@ -120,6 +127,7 @@ class ServerFeaturesDto { duplicateDetection: mapValueOfType(json, r'duplicateDetection')!, email: mapValueOfType(json, r'email')!, facialRecognition: mapValueOfType(json, r'facialRecognition')!, + importFaces: mapValueOfType(json, r'importFaces')!, map: mapValueOfType(json, r'map')!, oauth: mapValueOfType(json, r'oauth')!, oauthAutoLaunch: mapValueOfType(json, r'oauthAutoLaunch')!, @@ -180,6 +188,7 @@ class ServerFeaturesDto { 'duplicateDetection', 'email', 'facialRecognition', + 'importFaces', 'map', 'oauth', 'oauthAutoLaunch', diff --git a/mobile/openapi/lib/model/server_media_types_response_dto.dart b/mobile/openapi/lib/model/server_media_types_response_dto.dart index 35ddef1956..506cbb44b4 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -52,6 +52,7 @@ class ServerMediaTypesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerMediaTypesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerMediaTypesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index e23dc15c61..621ebfa294 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -40,6 +40,7 @@ class ServerPingResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerPingResponse? fromJson(dynamic value) { + upgradeDto(value, "ServerPingResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 6996e49aa5..654a34ee6b 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -58,6 +58,7 @@ class ServerStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_storage_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart index 89d97d32ea..8d12e77834 100644 --- a/mobile/openapi/lib/model/server_storage_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -76,6 +76,7 @@ class ServerStorageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStorageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStorageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index 65b9b9163e..69e1b2d2c8 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -40,6 +40,7 @@ class ServerThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerThemeDto? fromJson(dynamic value) { + upgradeDto(value, "ServerThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_version_history_response_dto.dart b/mobile/openapi/lib/model/server_version_history_response_dto.dart new file mode 100644 index 0000000000..c81cb0e8b9 --- /dev/null +++ b/mobile/openapi/lib/model/server_version_history_response_dto.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 ServerVersionHistoryResponseDto { + /// Returns a new [ServerVersionHistoryResponseDto] instance. + ServerVersionHistoryResponseDto({ + required this.createdAt, + required this.id, + required this.version, + }); + + DateTime createdAt; + + String id; + + String version; + + @override + bool operator ==(Object other) => identical(this, other) || other is ServerVersionHistoryResponseDto && + other.createdAt == createdAt && + other.id == id && + other.version == version; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (id.hashCode) + + (version.hashCode); + + @override + String toString() => 'ServerVersionHistoryResponseDto[createdAt=$createdAt, id=$id, version=$version]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'id'] = this.id; + json[r'version'] = this.version; + return json; + } + + /// Returns a new [ServerVersionHistoryResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ServerVersionHistoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionHistoryResponseDto"); + if (value is Map) { + final json = value.cast(); + + return ServerVersionHistoryResponseDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + id: mapValueOfType(json, r'id')!, + version: mapValueOfType(json, r'version')!, + ); + } + 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 = ServerVersionHistoryResponseDto.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 = ServerVersionHistoryResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ServerVersionHistoryResponseDto-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] = ServerVersionHistoryResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'version', + }; +} + diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index e507f3372a..751347fabd 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -52,6 +52,7 @@ class ServerVersionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerVersionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 82673b3874..92e2dc6067 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -70,6 +70,7 @@ class SessionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SessionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 623bc3125f..bc96b31fd2 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -132,6 +132,7 @@ class SharedLinkCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 2369c85db1..a394ba9b3b 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -141,6 +141,7 @@ class SharedLinkEditDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkEditDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkEditDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 018a1a51de..9cc8b3ac80 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -144,6 +144,7 @@ class SharedLinkResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index 772749fdba..7e0ff4045c 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -52,6 +52,7 @@ class SignUpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SignUpDto? fromJson(dynamic value) { + upgradeDto(value, "SignUpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 52e7c108b8..4631eccf2c 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -54,6 +54,7 @@ class SmartInfoResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartInfoResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SmartInfoResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 25927f4244..4e1408cafa 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -46,20 +46,8 @@ class SmartSearchDto { this.withExif, }); - /// - /// 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? city; - /// - /// 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? country; /// @@ -142,12 +130,6 @@ class SmartSearchDto { /// bool? isVisible; - /// - /// 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? lensModel; String? libraryId; @@ -160,12 +142,6 @@ class SmartSearchDto { /// String? make; - /// - /// 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? model; /// Minimum value: 1 @@ -191,12 +167,6 @@ class SmartSearchDto { /// num? size; - /// - /// 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? state; /// @@ -497,6 +467,7 @@ class SmartSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartSearchDto? fromJson(dynamic value) { + upgradeDto(value, "SmartSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/source_type.dart similarity index 52% rename from mobile/openapi/lib/model/map_theme.dart rename to mobile/openapi/lib/model/source_type.dart index e2553790c6..13c450b010 100644 --- a/mobile/openapi/lib/model/map_theme.dart +++ b/mobile/openapi/lib/model/source_type.dart @@ -11,9 +11,9 @@ part of openapi.api; -class MapTheme { +class SourceType { /// Instantiate a new enum with the provided [value]. - const MapTheme._(this.value); + const SourceType._(this.value); /// The underlying value of this enum member. final String value; @@ -23,22 +23,22 @@ class MapTheme { String toJson() => value; - static const light = MapTheme._(r'light'); - static const dark = MapTheme._(r'dark'); + static const machineLearning = SourceType._(r'machine-learning'); + static const exif = SourceType._(r'exif'); - /// List of all possible values in this [enum][MapTheme]. - static const values = [ - light, - dark, + /// List of all possible values in this [enum][SourceType]. + static const values = [ + machineLearning, + exif, ]; - static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value); + static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MapTheme.fromJson(row); + final value = SourceType.fromJson(row); if (value != null) { result.add(value); } @@ -48,16 +48,16 @@ class MapTheme { } } -/// Transformation class that can [encode] an instance of [MapTheme] to String, -/// and [decode] dynamic data back to [MapTheme]. -class MapThemeTypeTransformer { - factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._(); +/// Transformation class that can [encode] an instance of [SourceType] to String, +/// and [decode] dynamic data back to [SourceType]. +class SourceTypeTypeTransformer { + factory SourceTypeTypeTransformer() => _instance ??= const SourceTypeTypeTransformer._(); - const MapThemeTypeTransformer._(); + const SourceTypeTypeTransformer._(); - String encode(MapTheme data) => data.value; + String encode(SourceType data) => data.value; - /// Decodes a [dynamic value][data] to a MapTheme. + /// Decodes a [dynamic value][data] to a SourceType. /// /// 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] @@ -65,11 +65,11 @@ class MapThemeTypeTransformer { /// /// 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. - MapTheme? decode(dynamic data, {bool allowNull = true}) { + SourceType? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'light': return MapTheme.light; - case r'dark': return MapTheme.dark; + case r'machine-learning': return SourceType.machineLearning; + case r'exif': return SourceType.exif; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -79,7 +79,7 @@ class MapThemeTypeTransformer { return null; } - /// Singleton [MapThemeTypeTransformer] instance. - static MapThemeTypeTransformer? _instance; + /// Singleton [SourceTypeTypeTransformer] instance. + static SourceTypeTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart new file mode 100644 index 0000000000..cb51081eb1 --- /dev/null +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -0,0 +1,102 @@ +// +// 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 StackCreateDto { + /// Returns a new [StackCreateDto] instance. + StackCreateDto({ + this.assetIds = const [], + }); + + /// first asset becomes the primary + List assetIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackCreateDto && + _deepEquality.equals(other.assetIds, assetIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode); + + @override + String toString() => 'StackCreateDto[assetIds=$assetIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + return json; + } + + /// Returns a new [StackCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackCreateDto? fromJson(dynamic value) { + upgradeDto(value, "StackCreateDto"); + if (value is Map) { + final json = value.cast(); + + return StackCreateDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] 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 = StackCreateDto.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 = StackCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackCreateDto-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] = StackCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart new file mode 100644 index 0000000000..b6cb747caf --- /dev/null +++ b/mobile/openapi/lib/model/stack_response_dto.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 StackResponseDto { + /// Returns a new [StackResponseDto] instance. + StackResponseDto({ + this.assets = const [], + required this.id, + required this.primaryAssetId, + }); + + List assets; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackResponseDto && + _deepEquality.equals(other.assets, assets) && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assets.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'StackResponseDto[assets=$assets, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assets'] = this.assets; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [StackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "StackResponseDto"); + if (value is Map) { + final json = value.cast(); + + return StackResponseDto( + assets: AssetResponseDto.listFromJson(json[r'assets']), + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + 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 = StackResponseDto.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 = StackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackResponseDto-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] = StackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assets', + 'id', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart new file mode 100644 index 0000000000..0101499edf --- /dev/null +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -0,0 +1,108 @@ +// +// 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 StackUpdateDto { + /// Returns a new [StackUpdateDto] instance. + StackUpdateDto({ + this.primaryAssetId, + }); + + /// + /// 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? primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is StackUpdateDto && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (primaryAssetId == null ? 0 : primaryAssetId!.hashCode); + + @override + String toString() => 'StackUpdateDto[primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + if (this.primaryAssetId != null) { + json[r'primaryAssetId'] = this.primaryAssetId; + } else { + // json[r'primaryAssetId'] = null; + } + return json; + } + + /// Returns a new [StackUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static StackUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "StackUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return StackUpdateDto( + primaryAssetId: mapValueOfType(json, r'primaryAssetId'), + ); + } + 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 = StackUpdateDto.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 = StackUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of StackUpdateDto-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] = StackUpdateDto.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/system_config_backups_dto.dart b/mobile/openapi/lib/model/system_config_backups_dto.dart new file mode 100644 index 0000000000..82cd6e59eb --- /dev/null +++ b/mobile/openapi/lib/model/system_config_backups_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 SystemConfigBackupsDto { + /// Returns a new [SystemConfigBackupsDto] instance. + SystemConfigBackupsDto({ + required this.database, + }); + + DatabaseBackupConfig database; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto && + other.database == database; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (database.hashCode); + + @override + String toString() => 'SystemConfigBackupsDto[database=$database]'; + + Map toJson() { + final json = {}; + json[r'database'] = this.database; + return json; + } + + /// Returns a new [SystemConfigBackupsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigBackupsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigBackupsDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigBackupsDto( + database: DatabaseBackupConfig.fromJson(json[r'database'])!, + ); + } + 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 = SystemConfigBackupsDto.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 = SystemConfigBackupsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigBackupsDto-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] = SystemConfigBackupsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'database', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index e56169742a..4215953906 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class SystemConfigDto { /// Returns a new [SystemConfigDto] instance. SystemConfigDto({ + required this.backup, required this.ffmpeg, required this.image, required this.job, @@ -20,6 +21,7 @@ class SystemConfigDto { required this.logging, required this.machineLearning, required this.map, + required this.metadata, required this.newVersionCheck, required this.notifications, required this.oauth, @@ -32,6 +34,8 @@ class SystemConfigDto { required this.user, }); + SystemConfigBackupsDto backup; + SystemConfigFFmpegDto ffmpeg; SystemConfigImageDto image; @@ -46,6 +50,8 @@ class SystemConfigDto { SystemConfigMapDto map; + SystemConfigMetadataDto metadata; + SystemConfigNewVersionCheckDto newVersionCheck; SystemConfigNotificationsDto notifications; @@ -68,6 +74,7 @@ class SystemConfigDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && + other.backup == backup && other.ffmpeg == ffmpeg && other.image == image && other.job == job && @@ -75,6 +82,7 @@ class SystemConfigDto { other.logging == logging && other.machineLearning == machineLearning && other.map == map && + other.metadata == metadata && other.newVersionCheck == newVersionCheck && other.notifications == notifications && other.oauth == oauth && @@ -89,6 +97,7 @@ class SystemConfigDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (backup.hashCode) + (ffmpeg.hashCode) + (image.hashCode) + (job.hashCode) + @@ -96,6 +105,7 @@ class SystemConfigDto { (logging.hashCode) + (machineLearning.hashCode) + (map.hashCode) + + (metadata.hashCode) + (newVersionCheck.hashCode) + (notifications.hashCode) + (oauth.hashCode) + @@ -108,10 +118,11 @@ class SystemConfigDto { (user.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; + String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; Map toJson() { final json = {}; + json[r'backup'] = this.backup; json[r'ffmpeg'] = this.ffmpeg; json[r'image'] = this.image; json[r'job'] = this.job; @@ -119,6 +130,7 @@ class SystemConfigDto { json[r'logging'] = this.logging; json[r'machineLearning'] = this.machineLearning; json[r'map'] = this.map; + json[r'metadata'] = this.metadata; json[r'newVersionCheck'] = this.newVersionCheck; json[r'notifications'] = this.notifications; json[r'oauth'] = this.oauth; @@ -136,10 +148,12 @@ class SystemConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigDto"); if (value is Map) { final json = value.cast(); return SystemConfigDto( + backup: SystemConfigBackupsDto.fromJson(json[r'backup'])!, ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!, image: SystemConfigImageDto.fromJson(json[r'image'])!, job: SystemConfigJobDto.fromJson(json[r'job'])!, @@ -147,6 +161,7 @@ class SystemConfigDto { logging: SystemConfigLoggingDto.fromJson(json[r'logging'])!, machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!, map: SystemConfigMapDto.fromJson(json[r'map'])!, + metadata: SystemConfigMetadataDto.fromJson(json[r'metadata'])!, newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!, notifications: SystemConfigNotificationsDto.fromJson(json[r'notifications'])!, oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!, @@ -204,6 +219,7 @@ class SystemConfigDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'backup', 'ffmpeg', 'image', 'job', @@ -211,6 +227,7 @@ class SystemConfigDto { 'logging', 'machineLearning', 'map', + 'metadata', 'newVersionCheck', 'notifications', 'oauth', diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index a75a77c669..73f7d35aec 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -175,6 +175,7 @@ class SystemConfigFFmpegDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFFmpegDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFFmpegDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart similarity index 53% rename from mobile/openapi/lib/model/update_stack_parent_dto.dart rename to mobile/openapi/lib/model/system_config_faces_dto.dart index 4247c2e29f..4e18eb8de2 100644 --- a/mobile/openapi/lib/model/update_stack_parent_dto.dart +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -10,58 +10,52 @@ part of openapi.api; -class UpdateStackParentDto { - /// Returns a new [UpdateStackParentDto] instance. - UpdateStackParentDto({ - required this.newParentId, - required this.oldParentId, +class SystemConfigFacesDto { + /// Returns a new [SystemConfigFacesDto] instance. + SystemConfigFacesDto({ + required this.import_, }); - String newParentId; - - String oldParentId; + bool import_; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto && - other.newParentId == newParentId && - other.oldParentId == oldParentId; + bool operator ==(Object other) => identical(this, other) || other is SystemConfigFacesDto && + other.import_ == import_; @override int get hashCode => // ignore: unnecessary_parenthesis - (newParentId.hashCode) + - (oldParentId.hashCode); + (import_.hashCode); @override - String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]'; + String toString() => 'SystemConfigFacesDto[import_=$import_]'; Map toJson() { final json = {}; - json[r'newParentId'] = this.newParentId; - json[r'oldParentId'] = this.oldParentId; + json[r'import'] = this.import_; return json; } - /// Returns a new [UpdateStackParentDto] instance and imports its values from + /// Returns a new [SystemConfigFacesDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateStackParentDto? fromJson(dynamic value) { + static SystemConfigFacesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFacesDto"); if (value is Map) { final json = value.cast(); - return UpdateStackParentDto( - newParentId: mapValueOfType(json, r'newParentId')!, - oldParentId: mapValueOfType(json, r'oldParentId')!, + return SystemConfigFacesDto( + import_: mapValueOfType(json, r'import')!, ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateStackParentDto.fromJson(row); + final value = SystemConfigFacesDto.fromJson(row); if (value != null) { result.add(value); } @@ -70,12 +64,12 @@ class UpdateStackParentDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = UpdateStackParentDto.fromJson(entry.value); + final value = SystemConfigFacesDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -84,14 +78,14 @@ class UpdateStackParentDto { return map; } - // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of SystemConfigFacesDto-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] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = SystemConfigFacesDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -99,8 +93,7 @@ class UpdateStackParentDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'newParentId', - 'oldParentId', + 'import', }; } diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000..2192a7cb0c --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// 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 SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + 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 = SystemConfigGeneratedImageDto.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 = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-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] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 388949c759..5309f7745c 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -80,17 +58,15 @@ class SystemConfigImageDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigImageDto"); if (value is Map) { final json = value.cast(); return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -140,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 1bc0f6b29c..c0fed5cccc 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -100,6 +100,7 @@ class SystemConfigJobDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigJobDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigJobDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f55e33e80..e728b0bf20 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart index 31df272594..6a6558b4b3 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryScanDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryScanDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryScanDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 9d152f366a..1a1f5d7126 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -40,6 +40,7 @@ class SystemConfigLibraryWatchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryWatchDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryWatchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 60c0be3d2c..f025221eff 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLoggingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLoggingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLoggingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index 3923bacad4..d665f0bfa5 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -64,6 +64,7 @@ class SystemConfigMachineLearningDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMachineLearningDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMachineLearningDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index 6631885182..d53d5711db 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -52,6 +52,7 @@ class SystemConfigMapDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMapDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMapDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart new file mode 100644 index 0000000000..3c32fc551d --- /dev/null +++ b/mobile/openapi/lib/model/system_config_metadata_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 SystemConfigMetadataDto { + /// Returns a new [SystemConfigMetadataDto] instance. + SystemConfigMetadataDto({ + required this.faces, + }); + + SystemConfigFacesDto faces; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigMetadataDto && + other.faces == faces; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (faces.hashCode); + + @override + String toString() => 'SystemConfigMetadataDto[faces=$faces]'; + + Map toJson() { + final json = {}; + json[r'faces'] = this.faces; + return json; + } + + /// Returns a new [SystemConfigMetadataDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMetadataDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigMetadataDto( + faces: SystemConfigFacesDto.fromJson(json[r'faces'])!, + ); + } + 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 = SystemConfigMetadataDto.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 = SystemConfigMetadataDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigMetadataDto-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] = SystemConfigMetadataDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'faces', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index c7b8c98695..c63d2abc1b 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNewVersionCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNewVersionCheckDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNewVersionCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 22f08b3ab4..35d3d31833 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNotificationsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNotificationsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNotificationsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 6ebbe8d25c..9125bb7bba 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -125,6 +125,7 @@ class SystemConfigOAuthDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigOAuthDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigOAuthDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart index 61896a890c..69c8942bb6 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -40,6 +40,7 @@ class SystemConfigPasswordLoginDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigPasswordLoginDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 2eb586cac6..6c1673d46c 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -40,6 +40,7 @@ class SystemConfigReverseGeocodingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigReverseGeocodingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigReverseGeocodingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index ccb48ee61d..b1b92c9515 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -46,6 +46,7 @@ class SystemConfigServerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigServerDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigServerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index 6588d244ee..fcde49cf35 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -58,6 +58,7 @@ class SystemConfigSmtpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index 63dfdca4cf..bdaaa426c5 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -66,6 +66,7 @@ class SystemConfigSmtpTransportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpTransportDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpTransportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_storage_template_dto.dart b/mobile/openapi/lib/model/system_config_storage_template_dto.dart index 13323aebda..596aafc195 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -52,6 +52,7 @@ class SystemConfigStorageTemplateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigStorageTemplateDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigStorageTemplateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart index 82e0a6f747..f8586d344c 100644 --- a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart +++ b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart @@ -82,6 +82,7 @@ class SystemConfigTemplateStorageOptionDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateStorageOptionDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index 2f7f4d2f3b..a97c2cf84c 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -40,6 +40,7 @@ class SystemConfigThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigThemeDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 336019fde4..51b39e9a55 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -47,6 +47,7 @@ class SystemConfigTrashDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTrashDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTrashDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index c466374460..8e6bd3c9c3 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -41,6 +41,7 @@ class SystemConfigUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigUserDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart new file mode 100644 index 0000000000..26a575e193 --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -0,0 +1,111 @@ +// +// 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 TagBulkAssetsDto { + /// Returns a new [TagBulkAssetsDto] instance. + TagBulkAssetsDto({ + this.assetIds = const [], + this.tagIds = const [], + }); + + List assetIds; + + List tagIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsDto && + _deepEquality.equals(other.assetIds, assetIds) && + _deepEquality.equals(other.tagIds, tagIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (tagIds.hashCode); + + @override + String toString() => 'TagBulkAssetsDto[assetIds=$assetIds, tagIds=$tagIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + json[r'tagIds'] = this.tagIds; + return json; + } + + /// Returns a new [TagBulkAssetsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsDto"); + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] 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 = TagBulkAssetsDto.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 = TagBulkAssetsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsDto-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] = TagBulkAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + 'tagIds', + }; +} + diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart new file mode 100644 index 0000000000..009f26bfe4 --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_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 TagBulkAssetsResponseDto { + /// Returns a new [TagBulkAssetsResponseDto] instance. + TagBulkAssetsResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TagBulkAssetsResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TagBulkAssetsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + 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 = TagBulkAssetsResponseDto.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 = TagBulkAssetsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsResponseDto-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] = TagBulkAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart new file mode 100644 index 0000000000..9a5171074d --- /dev/null +++ b/mobile/openapi/lib/model/tag_create_dto.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 TagCreateDto { + /// Returns a new [TagCreateDto] instance. + TagCreateDto({ + this.color, + required this.name, + this.parentId, + }); + + /// + /// 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 name; + + String? parentId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagCreateDto && + other.color == color && + other.name == name && + other.parentId == parentId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (name.hashCode) + + (parentId == null ? 0 : parentId!.hashCode); + + @override + String toString() => 'TagCreateDto[color=$color, name=$name, parentId=$parentId]'; + + Map toJson() { + final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'name'] = this.name; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; + } + return json; + } + + /// Returns a new [TagCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagCreateDto? fromJson(dynamic value) { + upgradeDto(value, "TagCreateDto"); + if (value is Map) { + final json = value.cast(); + + return TagCreateDto( + color: mapValueOfType(json, r'color'), + name: mapValueOfType(json, r'name')!, + parentId: mapValueOfType(json, r'parentId'), + ); + } + 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 = TagCreateDto.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 = TagCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagCreateDto-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] = TagCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index d371bd1c04..cd684b163a 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -13,44 +13,82 @@ part of openapi.api; class TagResponseDto { /// Returns a new [TagResponseDto] instance. TagResponseDto({ + this.color, + required this.createdAt, required this.id, required this.name, - required this.type, - required this.userId, + this.parentId, + required this.updatedAt, + required this.value, }); + /// + /// 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; + + DateTime createdAt; + String id; String name; - TagTypeEnum type; + /// + /// 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? parentId; - String userId; + DateTime updatedAt; + + String value; @override bool operator ==(Object other) => identical(this, other) || other is TagResponseDto && + other.color == color && + other.createdAt == createdAt && other.id == id && other.name == name && - other.type == type && - other.userId == userId; + other.parentId == parentId && + other.updatedAt == updatedAt && + other.value == value; @override int get hashCode => // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + - (type.hashCode) + - (userId.hashCode); + (parentId == null ? 0 : parentId!.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); @override - String toString() => 'TagResponseDto[id=$id, name=$name, type=$type, userId=$userId]'; + String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, parentId=$parentId, updatedAt=$updatedAt, value=$value]'; Map toJson() { final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; - json[r'type'] = this.type; - json[r'userId'] = this.userId; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; + } + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; return json; } @@ -58,14 +96,18 @@ class TagResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagResponseDto"); if (value is Map) { final json = value.cast(); return TagResponseDto( + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, - userId: mapValueOfType(json, r'userId')!, + parentId: mapValueOfType(json, r'parentId'), + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, ); } return null; @@ -113,10 +155,11 @@ class TagResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'createdAt', 'id', 'name', - 'type', - 'userId', + 'updatedAt', + 'value', }; } diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart deleted file mode 100644 index 3f2e723796..0000000000 --- a/mobile/openapi/lib/model/tag_type_enum.dart +++ /dev/null @@ -1,88 +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 TagTypeEnum { - /// Instantiate a new enum with the provided [value]. - const TagTypeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const OBJECT = TagTypeEnum._(r'OBJECT'); - static const FACE = TagTypeEnum._(r'FACE'); - static const CUSTOM = TagTypeEnum._(r'CUSTOM'); - - /// List of all possible values in this [enum][TagTypeEnum]. - static const values = [ - OBJECT, - FACE, - CUSTOM, - ]; - - static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().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 = TagTypeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [TagTypeEnum] to String, -/// and [decode] dynamic data back to [TagTypeEnum]. -class TagTypeEnumTypeTransformer { - factory TagTypeEnumTypeTransformer() => _instance ??= const TagTypeEnumTypeTransformer._(); - - const TagTypeEnumTypeTransformer._(); - - String encode(TagTypeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a TagTypeEnum. - /// - /// 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. - TagTypeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'OBJECT': return TagTypeEnum.OBJECT; - case r'FACE': return TagTypeEnum.FACE; - case r'CUSTOM': return TagTypeEnum.CUSTOM; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [TagTypeEnumTypeTransformer] instance. - static TagTypeEnumTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/tag_update_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart new file mode 100644 index 0000000000..ab1adb127b --- /dev/null +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -0,0 +1,102 @@ +// +// 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 TagUpdateDto { + /// Returns a new [TagUpdateDto] instance. + TagUpdateDto({ + this.color, + }); + + String? color; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagUpdateDto && + other.color == color; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode); + + @override + String toString() => 'TagUpdateDto[color=$color]'; + + Map toJson() { + final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + return json; + } + + /// Returns a new [TagUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return TagUpdateDto( + color: mapValueOfType(json, r'color'), + ); + } + 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 = TagUpdateDto.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 = TagUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagUpdateDto-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] = TagUpdateDto.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/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart new file mode 100644 index 0000000000..d60a00f466 --- /dev/null +++ b/mobile/openapi/lib/model/tag_upsert_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 TagUpsertDto { + /// Returns a new [TagUpsertDto] instance. + TagUpsertDto({ + this.tags = const [], + }); + + List tags; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagUpsertDto && + _deepEquality.equals(other.tags, tags); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (tags.hashCode); + + @override + String toString() => 'TagUpsertDto[tags=$tags]'; + + Map toJson() { + final json = {}; + json[r'tags'] = this.tags; + return json; + } + + /// Returns a new [TagUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return TagUpsertDto( + tags: json[r'tags'] is Iterable + ? (json[r'tags'] 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 = TagUpsertDto.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 = TagUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagUpsertDto-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] = TagUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'tags', + }; +} + diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart new file mode 100644 index 0000000000..2470edf979 --- /dev/null +++ b/mobile/openapi/lib/model/tags_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 TagsResponse { + /// Returns a new [TagsResponse] instance. + TagsResponse({ + this.enabled = true, + this.sidebarWeb = true, + }); + + bool enabled; + + bool sidebarWeb; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagsResponse && + other.enabled == enabled && + other.sidebarWeb == sidebarWeb; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (sidebarWeb.hashCode); + + @override + String toString() => 'TagsResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'sidebarWeb'] = this.sidebarWeb; + return json; + } + + /// Returns a new [TagsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsResponse? fromJson(dynamic value) { + upgradeDto(value, "TagsResponse"); + if (value is Map) { + final json = value.cast(); + + return TagsResponse( + 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 = TagsResponse.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 = TagsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsResponse-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] = TagsResponse.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/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart new file mode 100644 index 0000000000..d992369140 --- /dev/null +++ b/mobile/openapi/lib/model/tags_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 TagsUpdate { + /// Returns a new [TagsUpdate] instance. + TagsUpdate({ + 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 TagsUpdate && + 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() => 'TagsUpdate[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 [TagsUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagsUpdate? fromJson(dynamic value) { + upgradeDto(value, "TagsUpdate"); + if (value is Map) { + final json = value.cast(); + + return TagsUpdate( + 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 = TagsUpdate.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 = TagsUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagsUpdate-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] = TagsUpdate.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/test_email_response_dto.dart b/mobile/openapi/lib/model/test_email_response_dto.dart new file mode 100644 index 0000000000..33e6c042d8 --- /dev/null +++ b/mobile/openapi/lib/model/test_email_response_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 TestEmailResponseDto { + /// Returns a new [TestEmailResponseDto] instance. + TestEmailResponseDto({ + required this.messageId, + }); + + String messageId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto && + other.messageId == messageId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (messageId.hashCode); + + @override + String toString() => 'TestEmailResponseDto[messageId=$messageId]'; + + Map toJson() { + final json = {}; + json[r'messageId'] = this.messageId; + return json; + } + + /// Returns a new [TestEmailResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TestEmailResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TestEmailResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TestEmailResponseDto( + messageId: mapValueOfType(json, r'messageId')!, + ); + } + 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 = TestEmailResponseDto.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 = TestEmailResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TestEmailResponseDto-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] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'messageId', + }; +} + diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 2c86a56b3c..56044b27a8 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -46,6 +46,7 @@ class TimeBucketResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TimeBucketResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/trash_response_dto.dart b/mobile/openapi/lib/model/trash_response_dto.dart new file mode 100644 index 0000000000..2df154d06c --- /dev/null +++ b/mobile/openapi/lib/model/trash_response_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 TrashResponseDto { + /// Returns a new [TrashResponseDto] instance. + TrashResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TrashResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TrashResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TrashResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TrashResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TrashResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TrashResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + 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 = TrashResponseDto.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 = TrashResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TrashResponseDto-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] = TrashResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index f9c9762887..8353dba14e 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -114,6 +114,7 @@ class UpdateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index f77223acf5..43218cae6e 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -40,6 +40,7 @@ class UpdateAlbumUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumUserDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index e9a4d8d6b8..9ebce5fd92 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -18,7 +18,9 @@ class UpdateAssetDto { this.isArchived, this.isFavorite, this.latitude, + this.livePhotoVideoId, this.longitude, + this.rating, }); /// @@ -61,6 +63,8 @@ class UpdateAssetDto { /// num? latitude; + String? livePhotoVideoId; + /// /// 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 @@ -69,6 +73,16 @@ class UpdateAssetDto { /// num? longitude; + /// Minimum value: 0 + /// 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; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && other.dateTimeOriginal == dateTimeOriginal && @@ -76,7 +90,9 @@ class UpdateAssetDto { other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && - other.longitude == longitude; + other.livePhotoVideoId == livePhotoVideoId && + other.longitude == longitude && + other.rating == rating; @override int get hashCode => @@ -86,10 +102,12 @@ class UpdateAssetDto { (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + - (longitude == null ? 0 : longitude!.hashCode); + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + + (longitude == null ? 0 : longitude!.hashCode) + + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -118,11 +136,21 @@ class UpdateAssetDto { } else { // json[r'latitude'] = null; } + if (this.livePhotoVideoId != null) { + json[r'livePhotoVideoId'] = this.livePhotoVideoId; + } else { + // json[r'livePhotoVideoId'] = null; + } if (this.longitude != null) { json[r'longitude'] = this.longitude; } else { // json[r'longitude'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } return json; } @@ -130,6 +158,7 @@ class UpdateAssetDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAssetDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAssetDto"); if (value is Map) { final json = value.cast(); @@ -139,7 +168,9 @@ class UpdateAssetDto { isArchived: mapValueOfType(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), + rating: num.parse('${json[r'rating']}'), ); } return null; diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index 85847c0ddf..b85df40172 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -62,6 +62,7 @@ class UpdateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index f695f99535..3af3c83ad1 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -40,6 +40,7 @@ class UpdatePartnerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdatePartnerDto? fromJson(dynamic value) { + upgradeDto(value, "UpdatePartnerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index 0bbbba00bb..e6f9216d74 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -74,6 +74,7 @@ class UsageByUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UsageByUserDto? fromJson(dynamic value) { + upgradeDto(value, "UsageByUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index db514a1d57..f2709be57b 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -105,6 +105,7 @@ class UserAdminCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminCreateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_delete_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart index 7778b15775..2cf68ad7b2 100644 --- a/mobile/openapi/lib/model/user_admin_delete_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -50,6 +50,7 @@ class UserAdminDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index af1ad3ad1c..e5ae8e1d4e 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -22,6 +22,7 @@ class UserAdminResponseDto { required this.license, required this.name, required this.oauthId, + required this.profileChangedAt, required this.profileImagePath, required this.quotaSizeInBytes, required this.quotaUsageInBytes, @@ -49,6 +50,8 @@ class UserAdminResponseDto { String oauthId; + DateTime profileChangedAt; + String profileImagePath; int? quotaSizeInBytes; @@ -74,6 +77,7 @@ class UserAdminResponseDto { other.license == license && other.name == name && other.oauthId == oauthId && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath && other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && @@ -94,6 +98,7 @@ class UserAdminResponseDto { (license == null ? 0 : license!.hashCode) + (name.hashCode) + (oauthId.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + @@ -103,7 +108,7 @@ class UserAdminResponseDto { (updatedAt.hashCode); @override - String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -124,6 +129,7 @@ class UserAdminResponseDto { } json[r'name'] = this.name; json[r'oauthId'] = this.oauthId; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; if (this.quotaSizeInBytes != null) { json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; @@ -150,6 +156,7 @@ class UserAdminResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminResponseDto"); if (value is Map) { final json = value.cast(); @@ -163,6 +170,7 @@ class UserAdminResponseDto { license: UserLicense.fromJson(json[r'license']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), @@ -226,6 +234,7 @@ class UserAdminResponseDto { 'license', 'name', 'oauthId', + 'profileChangedAt', 'profileImagePath', 'quotaSizeInBytes', 'quotaUsageInBytes', diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index dd0db767fe..6c6f73ae8e 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -119,6 +119,7 @@ class UserAdminUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart index c7abb085f2..9bed8d5c43 100644 --- a/mobile/openapi/lib/model/user_license.dart +++ b/mobile/openapi/lib/model/user_license.dart @@ -52,6 +52,7 @@ class UserLicense { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserLicense? fromJson(dynamic value) { + upgradeDto(value, "UserLicense"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 21b96bb557..23d9ea84ec 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -16,8 +16,12 @@ class UserPreferencesResponseDto { required this.avatar, required this.download, required this.emailNotifications, + required this.folders, required this.memories, + required this.people, required this.purchase, + required this.ratings, + required this.tags, }); AvatarResponse avatar; @@ -26,17 +30,29 @@ class UserPreferencesResponseDto { EmailNotificationsResponse emailNotifications; - MemoryResponse memories; + FoldersResponse folders; + + MemoriesResponse memories; + + PeopleResponse people; PurchaseResponse purchase; + RatingsResponse ratings; + + TagsResponse tags; + @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && - other.purchase == purchase; + other.people == people && + other.purchase == purchase && + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -44,19 +60,27 @@ class UserPreferencesResponseDto { (avatar.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + + (folders.hashCode) + (memories.hashCode) + - (purchase.hashCode); + (people.hashCode) + + (purchase.hashCode) + + (ratings.hashCode) + + (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; json[r'avatar'] = this.avatar; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; + json[r'folders'] = this.folders; json[r'memories'] = this.memories; + json[r'people'] = this.people; json[r'purchase'] = this.purchase; + json[r'ratings'] = this.ratings; + json[r'tags'] = this.tags; return json; } @@ -64,6 +88,7 @@ class UserPreferencesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesResponseDto"); if (value is Map) { final json = value.cast(); @@ -71,8 +96,12 @@ class UserPreferencesResponseDto { avatar: AvatarResponse.fromJson(json[r'avatar'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, - memories: MemoryResponse.fromJson(json[r'memories'])!, + folders: FoldersResponse.fromJson(json[r'folders'])!, + memories: MemoriesResponse.fromJson(json[r'memories'])!, + people: PeopleResponse.fromJson(json[r'people'])!, purchase: PurchaseResponse.fromJson(json[r'purchase'])!, + ratings: RatingsResponse.fromJson(json[r'ratings'])!, + tags: TagsResponse.fromJson(json[r'tags'])!, ); } return null; @@ -123,8 +152,12 @@ class UserPreferencesResponseDto { 'avatar', 'download', 'emailNotifications', + 'folders', 'memories', + 'people', 'purchase', + 'ratings', + 'tags', }; } diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 616883a60a..208dbf6860 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -16,8 +16,12 @@ class UserPreferencesUpdateDto { this.avatar, this.download, this.emailNotifications, + this.folders, this.memories, + this.people, this.purchase, + this.ratings, + this.tags, }); /// @@ -50,7 +54,23 @@ class UserPreferencesUpdateDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - MemoryUpdate? memories; + FoldersUpdate? folders; + + /// + /// 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. + /// + MemoriesUpdate? memories; + + /// + /// 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. + /// + PeopleUpdate? people; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -60,13 +80,33 @@ class UserPreferencesUpdateDto { /// PurchaseUpdate? purchase; + /// + /// 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. + /// + 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. + /// + TagsUpdate? tags; + @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && + other.folders == folders && other.memories == memories && - other.purchase == purchase; + other.people == people && + other.purchase == purchase && + other.ratings == ratings && + other.tags == tags; @override int get hashCode => @@ -74,11 +114,15 @@ class UserPreferencesUpdateDto { (avatar == null ? 0 : avatar!.hashCode) + (download == null ? 0 : download!.hashCode) + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + + (folders == null ? 0 : folders!.hashCode) + (memories == null ? 0 : memories!.hashCode) + - (purchase == null ? 0 : purchase!.hashCode); + (people == null ? 0 : people!.hashCode) + + (purchase == null ? 0 : purchase!.hashCode) + + (ratings == null ? 0 : ratings!.hashCode) + + (tags == null ? 0 : tags!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories, purchase=$purchase]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, tags=$tags]'; Map toJson() { final json = {}; @@ -97,16 +141,36 @@ class UserPreferencesUpdateDto { } else { // json[r'emailNotifications'] = null; } + if (this.folders != null) { + json[r'folders'] = this.folders; + } else { + // json[r'folders'] = null; + } if (this.memories != null) { json[r'memories'] = this.memories; } else { // json[r'memories'] = null; } + if (this.people != null) { + json[r'people'] = this.people; + } else { + // json[r'people'] = null; + } if (this.purchase != null) { json[r'purchase'] = this.purchase; } else { // json[r'purchase'] = null; } + if (this.ratings != null) { + json[r'ratings'] = this.ratings; + } else { + // json[r'ratings'] = null; + } + if (this.tags != null) { + json[r'tags'] = this.tags; + } else { + // json[r'tags'] = null; + } return json; } @@ -114,6 +178,7 @@ class UserPreferencesUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesUpdateDto"); if (value is Map) { final json = value.cast(); @@ -121,8 +186,12 @@ class UserPreferencesUpdateDto { avatar: AvatarUpdate.fromJson(json[r'avatar']), download: DownloadUpdate.fromJson(json[r'download']), emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), - memories: MemoryUpdate.fromJson(json[r'memories']), + folders: FoldersUpdate.fromJson(json[r'folders']), + memories: MemoriesUpdate.fromJson(json[r'memories']), + people: PeopleUpdate.fromJson(json[r'people']), purchase: PurchaseUpdate.fromJson(json[r'purchase']), + ratings: RatingsUpdate.fromJson(json[r'ratings']), + tags: TagsUpdate.fromJson(json[r'tags']), ); } return null; diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 41c1899848..a02da29948 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -17,6 +17,7 @@ class UserResponseDto { required this.email, required this.id, required this.name, + required this.profileChangedAt, required this.profileImagePath, }); @@ -28,6 +29,8 @@ class UserResponseDto { String name; + DateTime profileChangedAt; + String profileImagePath; @override @@ -36,6 +39,7 @@ class UserResponseDto { other.email == email && other.id == id && other.name == name && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath; @override @@ -45,10 +49,11 @@ class UserResponseDto { (email.hashCode) + (id.hashCode) + (name.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode); @override - String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]'; + String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; @@ -56,6 +61,7 @@ class UserResponseDto { json[r'email'] = this.email; json[r'id'] = this.id; json[r'name'] = this.name; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -64,6 +70,7 @@ class UserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserResponseDto"); if (value is Map) { final json = value.cast(); @@ -72,6 +79,7 @@ class UserResponseDto { email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } @@ -124,6 +132,7 @@ class UserResponseDto { 'email', 'id', 'name', + 'profileChangedAt', 'profileImagePath', }; } diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 2d665fc784..8f3f4df37a 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -82,6 +82,7 @@ class UserUpdateMeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserUpdateMeDto? fromJson(dynamic value) { + upgradeDto(value, "UserUpdateMeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_access_token_response_dto.dart b/mobile/openapi/lib/model/validate_access_token_response_dto.dart index e970f7e840..5e36efcfed 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -40,6 +40,7 @@ class ValidateAccessTokenResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateAccessTokenResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateAccessTokenResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 05e122b1a1..08199e3aa6 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -46,6 +46,7 @@ class ValidateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 23aac0b742..11fbbd74c2 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -62,6 +62,7 @@ class ValidateLibraryImportPathResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryImportPathResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index b213f9ba98..e0dc2a2d14 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -40,6 +40,7 @@ class ValidateLibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/pubspec.yaml b/mobile/openapi/pubspec.yaml index f033028432..1c26f8707c 100644 --- a/mobile/openapi/pubspec.yaml +++ b/mobile/openapi/pubspec.yaml @@ -7,11 +7,11 @@ version: '1.0.0' description: 'OpenAPI API client' homepage: 'homepage' environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.12.0 <4.0.0' dependencies: - collection: '^1.17.0' - http: '>=0.13.0 <0.14.0' + collection: '>=1.17.0 <2.0.0' + http: '>=0.13.0 <2.0.0' intl: any - meta: '^1.1.8' -dev_dependencies: - test: '>=1.21.6 <1.22.0' + meta: '>=1.1.8 <2.0.0' + immich_mobile: + path: ../ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a974e65518..01d9a7de64 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -37,18 +37,18 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" async: dependency: "direct main" description: @@ -61,18 +61,26 @@ packages: dependency: "direct main" description: name: auto_route - sha256: "6cad3f408863ffff2b5757967c802b18415dac4acb1b40c5cdd45d0a26e5080f" + sha256: b83e8ce46da7228cdd019b5a11205454847f0a971bca59a7529b98df9876889b url: "https://pub.dev" source: hosted - version: "8.1.3" + version: "9.2.2" auto_route_generator: dependency: "direct dev" description: name: auto_route_generator - sha256: ba28133d3a3bf0a66772bcc98dade5843753cd9f1a8fb4802b842895515b67d3 + sha256: c9086eb07271e51b44071ad5cff34e889f3156710b964a308c2ab590769e79e6 url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "91448c0fcb41af14ede14485c33b8ca684fcd6c0ac0a439be9f83fa964753e13" + url: "https://pub.dev" + source: hosted + version: "8.6.0" boolean_selector: dependency: transitive description: @@ -117,10 +125,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.13" build_runner_core: dependency: transitive description: @@ -221,10 +229,10 @@ packages: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.2" clock: dependency: transitive description: @@ -237,10 +245,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "4ad01d6e56db961d29661561effde45e519939fdaeb46c351275b182eac70189" + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.10.1" collection: dependency: "direct main" description: @@ -253,18 +261,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + sha256: "876849631b0c7dc20f8b471a2a03142841b482438e3b707955464f5ffca3e4c3" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.1.0" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.0.1" convert: dependency: transitive description: @@ -285,10 +293,10 @@ packages: dependency: transitive description: name: cross_file - sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" url: "https://pub.dev" source: hosted - version: "0.3.3+8" + version: "0.3.4+2" crypto: dependency: transitive description: @@ -341,10 +349,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.7" dartx: dependency: transitive description: @@ -357,26 +365,34 @@ packages: dependency: transitive description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" + sha256: c4af09051b4f0508f6c1dc0a5c085bf014d5c9a4a0678ce1799c2b4d716387a0 url: "https://pub.dev" source: hosted - version: "9.1.1" + version: "11.1.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d + url: "https://pub.dev" + source: hosted + version: "1.7.0" easy_image_viewer: dependency: "direct main" description: @@ -413,10 +429,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" file: dependency: transitive description: @@ -511,42 +527,42 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.1" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: c18f1de98fe0bb9dd5ba91e1330d4febc8b6a7de6aae3ffe475ef423723e72f3 + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" url: "https://pub.dev" source: hosted - version: "16.3.2" + version: "17.2.4" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af url: "https://pub.dev" source: hosted - version: "4.0.0+1" + version: "4.0.1" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" url: "https://pub.dev" source: hosted - version: "7.0.0+1" + version: "7.2.0" flutter_localizations: dependency: transitive description: flutter @@ -731,10 +747,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -755,10 +771,10 @@ packages: dependency: transitive description: name: image - sha256: "004a2e90ce080f8627b5a04aecb4cdfac87d2c3f3b520aa291260be5a32c033d" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.1.4" + version: "4.3.0" image_picker: dependency: "direct main" description: @@ -823,6 +839,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + immich_mobile_immich_lint: + dependency: "direct dev" + description: + path: immich_lint + relative: true + source: path + version: "0.0.0" integration_test: dependency: "direct dev" description: flutter @@ -880,26 +903,26 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -912,10 +935,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" logging: dependency: "direct main" description: @@ -927,30 +950,27 @@ packages: maplibre_gl: dependency: "direct main" description: - path: "." - ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl + sha256: "9dd9eebee52f42a45aaa9cdb912afa47845c37007b26a799aa482ecd368804c8" + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" maplibre_gl_platform_interface: dependency: transitive description: - path: maplibre_gl_platform_interface - ref: main - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl_platform_interface + sha256: a95fa38a3532253f32dfe181389adfe9f402773e58ac902d9c4efad3209e0903 + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" maplibre_gl_web: dependency: transitive description: - path: maplibre_gl_web - ref: main - resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - url: "https://github.com/maplibre/flutter-maplibre-gl.git" - source: git - version: "0.18.0" + name: maplibre_gl_web + sha256: "7f1540b384f16f3c9bc8b4ebdfca96fb07f6dab5d9ef4dd0e102985dba238691" + url: "https://pub.dev" + source: hosted + version: "0.19.0+2" matcher: dependency: transitive description: @@ -963,10 +983,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: "direct overridden" description: @@ -1034,18 +1054,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" + sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998 url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "8.1.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" path: dependency: "direct main" description: @@ -1178,26 +1198,26 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "68d6099d07ce5033170f8368af8128a4555cf1d590a97242f83669552de989b1" + sha256: "1ba9339f8dd759f2c341b9225c0104026ed94bac6748e3ab0b498a306536c3a3" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.6.0" photo_manager_image_provider: dependency: "direct main" description: name: photo_manager_image_provider - sha256: c187f60c3fdbe5630735d9a0bccbb071397ec03dcb1ba6085c29c8adece798a0 + sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -1206,14 +1226,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" - url: "https://pub.dev" - source: hosted - version: "3.7.3" pool: dependency: transitive description: @@ -1346,18 +1358,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "3ef39599b00059db0990ca2e30fca0a29d8b37aae924d60063f8e0184cf20900" + sha256: "3af2cda1752e5c24f2fc04b6083b40f013ffe84fb90472f30c6499a9213d5442" url: "https://pub.dev" source: hosted - version: "7.2.2" + version: "10.1.1" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "5.0.1" shared_preferences: dependency: transitive description: @@ -1455,10 +1467,10 @@ packages: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_span: dependency: transitive description: @@ -1551,10 +1563,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" thumbhash: dependency: "direct main" description: @@ -1647,26 +1659,26 @@ packages: dependency: transitive description: name: url_launcher_platform_interface - sha256: "4aca1e060978e19b2998ee28503f40b5ba6226819c2b5e3e4d1821e8ccd92198" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" uuid: dependency: transitive description: @@ -1751,26 +1763,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d + sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.8" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" + sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" watcher: dependency: transitive description: @@ -1783,10 +1795,10 @@ packages: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "1.1.0" web_socket_channel: dependency: transitive description: @@ -1807,10 +1819,10 @@ packages: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "10169d3934549017f0ae278ccb07f828f9d6ea21573bab0fb77b0e1ef0fce454" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.7.2" win32_registry: dependency: transitive description: @@ -1852,5 +1864,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.3" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e2072dd64a..e3d30c6173 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,46 +2,40 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.111.0+152 +version: 1.119.1+164 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.22.3 + flutter: 3.24.3 dependencies: flutter: sdk: flutter path_provider_ios: - # TODO: upgrade to stable after 3.0.1 is released. 3.0.0 is broken - # https://github.com/fluttercandies/flutter_photo_manager/pull/990#issuecomment-2058066427 - photo_manager: ^3.2.0 - photo_manager_image_provider: ^2.1.0 + photo_manager: ^3.5.1 + photo_manager_image_provider: ^2.2.0 flutter_hooks: ^0.20.4 hooks_riverpod: ^2.4.9 riverpod_annotation: ^2.3.3 cached_network_image: ^3.3.1 flutter_cache_manager: ^3.3.1 intl: ^0.19.0 - auto_route: ^8.0.2 + auto_route: ^9.2.0 fluttertoast: ^8.2.4 video_player: ^2.8.2 chewie: ^1.7.4 socket_io_client: ^2.0.3+1 - # TODO: Update it to tag once next stable release - maplibre_gl: - git: - url: https://github.com/maplibre/flutter-maplibre-gl.git - ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 + maplibre_gl: 0.19.0+2 geolocator: ^11.0.0 # used to move to current location in map view flutter_udid: ^3.0.0 flutter_svg: ^2.0.9 - package_info_plus: ^5.0.1 + package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 - share_plus: ^7.2.2 + share_plus: ^10.0.0 flutter_displaymode: ^0.6.0 scrollable_positioned_list: ^0.3.8 path: ^1.8.3 @@ -53,19 +47,22 @@ dependencies: isar: ^3.1.0+1 isar_flutter_libs: ^3.1.0+1 permission_handler: ^11.2.0 - device_info_plus: ^9.1.1 - connectivity_plus: ^5.0.2 + device_info_plus: ^11.0.0 + connectivity_plus: ^6.0.0 wakelock_plus: ^1.1.4 - flutter_local_notifications: ^16.3.2 + flutter_local_notifications: ^17.2.1+2 timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 async: ^2.11.0 + dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 share_handler: ^0.0.21 share_handler_ios: #image editing packages crop_image: ^1.0.13 + openapi: path: openapi @@ -92,18 +89,20 @@ dependency_overrides: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^5.0.0 build_runner: ^2.4.8 - auto_route_generator: ^8.0.0 - flutter_launcher_icons: ^0.13.1 + auto_route_generator: ^9.0.0 + flutter_launcher_icons: ^0.14.0 flutter_native_splash: ^2.3.9 isar_generator: ^3.1.0+1 integration_test: sdk: flutter - custom_lint: ^0.6.0 + custom_lint: ^0.6.4 riverpod_lint: ^2.3.7 riverpod_generator: ^2.3.9 mocktail: ^1.0.3 + immich_mobile_immich_lint: + path: './immich_lint' flutter: uses-material-design: true diff --git a/mobile/scripts/check_i18n_keys.py b/mobile/scripts/check_i18n_keys.py index 8d748ceb06..c3b53dc5a6 100644 --- a/mobile/scripts/check_i18n_keys.py +++ b/mobile/scripts/check_i18n_keys.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 import json import subprocess - def main(): - with open('assets/i18n/en-US.json', 'r') as f: + with open('assets/i18n/en-US.json', 'r+') as f: data = json.load(f) + keys_to_delete = [] for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="*.dart" "{k}"']) + sp = subprocess.run(['sh', '-c', f'grep -q -r --include="*.dart" "{k}"']) if sp.returncode != 0: - print("Not found in source code!") - return 1 + print("Not found in source code, key:", k) + keys_to_delete.append(k) + + for k in keys_to_delete: + del data[k] + + f.seek(0) + f.truncate() + json.dump(data, f, indent=4) if __name__ == '__main__': main() \ No newline at end of file diff --git a/mobile/scripts/check_key_uniform.py b/mobile/scripts/check_key_uniform.py deleted file mode 100644 index 970f491f36..0000000000 --- a/mobile/scripts/check_key_uniform.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -import json -import subprocess - -def main(): - print("CHECK GERMAN TRANSLATIONS") - with open('assets/i18n/de-DE.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - - print("CHECK FRENCH TRANSLATIONS") - with open('assets/i18n/fr-FR.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh old mode 100644 new mode 100755 index 44f59c69ae..41517737c9 --- a/mobile/scripts/fdroid_build_isar.sh +++ b/mobile/scripts/fdroid_build_isar.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh -cd .isar || exit +test -d .isar || exit +cp .isar-cargo.lock .isar/Cargo.lock +(cd .isar || exit bash tool/build_android.sh x86 bash tool/build_android.sh x64 bash tool/build_android.sh armv7 @@ -13,4 +15,4 @@ mv libisar_android_x64.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar_android_x86.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ -cd .. \ No newline at end of file +) \ No newline at end of file diff --git a/mobile/scripts/fdroid_update_isar.sh b/mobile/scripts/fdroid_update_isar.sh new file mode 100755 index 0000000000..814f50a8a1 --- /dev/null +++ b/mobile/scripts/fdroid_update_isar.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +isar_version="$(awk '/isar: /{gsub(/\^/, "", $2); print $2}' pubspec.yaml)" +checked_out_version="$(git -C .isar describe --tags)" + +if [ "$isar_version" = "$checked_out_version" ]; then + echo "isar is up-to-date." + exit 0 +fi +echo "Updating from version $checked_out_version to $isar_version." + +git -C .isar checkout "$isar_version" +cargo generate-lockfile --manifest-path .isar/Cargo.toml +mv .isar/Cargo.lock .isar-cargo.lock diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index b173dd2ac5..26108d63b2 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -17,7 +17,6 @@ final class AssetStub { isFavorite: true, isArchived: false, isTrashed: false, - stackCount: 0, ); static final image2 = Asset( @@ -34,6 +33,5 @@ final class AssetStub { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart index 9edabcc0d0..0216528ddd 100644 --- a/mobile/test/modules/activity/activity_statistics_provider_test.dart +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -1,5 +1,6 @@ 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/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:mocktail/mocktail.dart'; @@ -25,7 +26,7 @@ void main() { test('Returns the proper count family', () async { when( () => activityMock.getStatistics('test-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 5); + ).thenAnswer((_) async => const ActivityStats(comments: 5)); // Read here to make the getStatistics call container.read(activityStatisticsProvider('test-album', 'test-asset')); @@ -50,7 +51,7 @@ void main() { test('Adds activity', () async { when( () => activityMock.getStatistics('test-album'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('test-album'); container.listen( @@ -71,7 +72,7 @@ void main() { test('Removes activity', () async { when( () => activityMock.getStatistics('new-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('new-album', 'test-asset'); container.listen( diff --git a/mobile/test/modules/extensions/asset_extensions_test.dart b/mobile/test/modules/extensions/asset_extensions_test.dart index b90879acc7..d2b9b93d62 100644 --- a/mobile/test/modules/extensions/asset_extensions_test.dart +++ b/mobile/test/modules/extensions/asset_extensions_test.dart @@ -34,7 +34,6 @@ Asset makeAsset({ isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, exifInfo: exifInfo, ); } diff --git a/mobile/test/modules/home/asset_grid_data_structure_test.dart b/mobile/test/modules/home/asset_grid_data_structure_test.dart index f12b9b2190..b4ee851969 100644 --- a/mobile/test/modules/home/asset_grid_data_structure_test.dart +++ b/mobile/test/modules/home/asset_grid_data_structure_test.dart @@ -25,7 +25,6 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ), ); } diff --git a/mobile/test/modules/shared/shared_mocks.dart b/mobile/test/modules/shared/shared_mocks.dart index a2aa7b2617..013232da3e 100644 --- a/mobile/test/modules/shared/shared_mocks.dart +++ b/mobile/test/modules/shared/shared_mocks.dart @@ -1,11 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/hash.service.dart'; import 'package:mocktail/mocktail.dart'; -class MockHashService extends Mock implements HashService {} - class MockCurrentUserProvider extends StateNotifier with Mock implements CurrentUserProvider { diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 24f0c443ba..c85487c7d0 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,16 +1,21 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.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/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:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; +import '../../repository.mocks.dart'; +import '../../service.mocks.dart'; import '../../test_utils.dart'; -import 'shared_mocks.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -19,6 +24,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -32,13 +38,20 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, - stackCount: 0, ); } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); + final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); + final MockAlbumMediaRepository albumMediaRepository = + MockAlbumMediaRepository(); + final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final owner = User( id: "1", updatedAt: DateTime.now(), @@ -46,9 +59,10 @@ void main() { name: "first last", isAdmin: false, ); + late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -62,19 +76,51 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); + s = SyncService( + hs, + entityService, + albumMediaRepository, + albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); }); test('test inserting existing assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -82,11 +128,10 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), @@ -95,7 +140,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -103,11 +147,14 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "1-1"), @@ -116,7 +163,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -124,7 +170,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -132,7 +183,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -141,7 +198,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -151,11 +207,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { - SyncService s = SyncService(db, hs); + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -163,6 +229,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -170,7 +238,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart new file mode 100644 index 0000000000..b956c4bfb9 --- /dev/null +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openapi/api.dart'; +import 'package:immich_mobile/utils/openapi_patching.dart'; + +void main() { + group('Test OpenApi Patching', () { + test('upgradeDto', () { + dynamic value; + String targetType; + + targetType = 'UserPreferencesResponseDto'; + value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + + upgradeDto(value, targetType); + expect(value['tags'], TagsResponse().toJson()); + expect(value['download']['includeEmbeddedVideos'], false); + }); + + test('addDefault', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + String keys = 'download.unknownKey'; + dynamic defaultValue = 69420; + + addDefault(value, keys, defaultValue); + expect(value['download']['unknownKey'], 69420); + + keys = 'alpha.beta'; + defaultValue = 'gamma'; + addDefault(value, keys, defaultValue); + expect(value['alpha']['beta'], 'gamma'); + }); + }); +} diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart new file mode 100644 index 0000000000..c76a003eec --- /dev/null +++ b/mobile/test/repository.mocks.dart @@ -0,0 +1,31 @@ +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/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.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'; + +class MockAlbumRepository extends Mock implements IAlbumRepository {} + +class MockAssetRepository extends Mock implements IAssetRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockBackupRepository extends Mock implements IBackupRepository {} + +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + +class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} + +class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} + +class MockFileMediaRepository extends Mock implements IFileMediaRepository {} + +class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart new file mode 100644 index 0000000000..de49a98cc4 --- /dev/null +++ b/mobile/test/service.mocks.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSyncService extends Mock implements SyncService {} + +class MockHashService extends Mock implements HashService {} + +class MockEntityService extends Mock implements EntityService {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart new file mode 100644 index 0000000000..848d7cfad7 --- /dev/null +++ b/mobile/test/services/album.service_test.dart @@ -0,0 +1,218 @@ +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'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +void main() { + late AlbumService sut; + late MockUserService userService; + late MockSyncService syncService; + late MockEntityService entityService; + late MockAlbumRepository albumRepository; + late MockAssetRepository assetRepository; + late MockBackupRepository backupRepository; + late MockAlbumMediaRepository albumMediaRepository; + late MockAlbumApiRepository albumApiRepository; + + setUp(() { + userService = MockUserService(); + syncService = MockSyncService(); + entityService = MockEntityService(); + albumRepository = MockAlbumRepository(); + assetRepository = MockAssetRepository(); + backupRepository = MockBackupRepository(); + albumMediaRepository = MockAlbumMediaRepository(); + albumApiRepository = MockAlbumApiRepository(); + + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + + sut = AlbumService( + userService, + syncService, + entityService, + albumRepository, + assetRepository, + backupRepository, + albumMediaRepository, + albumApiRepository, + ); + }); + + group('refreshDeviceAlbums', () { + test('empty selection with one album in db', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => []); + when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); + when(() => syncService.removeAllLocalAlbumsAndAssets()) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, false); + verify(() => syncService.removeAllLocalAlbumsAndAssets()); + }); + + test('one selected albums, two on device', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => [AlbumStub.oneAsset.localId!]); + when(() => albumMediaRepository.getAll()) + .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when(() => syncService.syncLocalAlbumAssetsToDb(any(), any())) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, true); + verify( + () => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null), + ).called(1); + verifyNoMoreInteractions(syncService); + }); + }); + + group('refreshRemoteAlbums', () { + test('is working', () async { + when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => albumApiRepository.getAll(shared: true)) + .thenAnswer((_) async => [AlbumStub.sharedWithUser]); + + when(() => albumApiRepository.getAll(shared: null)) + .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + + when( + () => syncService.syncRemoteAlbumsToDb([ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ]), + ).thenAnswer((_) async => true); + final result = await sut.refreshRemoteAlbums(); + expect(result, true); + verify(() => userService.refreshUsers()).called(1); + verify(() => albumApiRepository.getAll(shared: true)).called(1); + verify(() => albumApiRepository.getAll(shared: null)).called(1); + verify( + () => syncService.syncRemoteAlbumsToDb( + [ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ], + ), + ).called(1); + verifyNoMoreInteractions(userService); + verifyNoMoreInteractions(albumApiRepository); + verifyNoMoreInteractions(syncService); + }); + }); + + group('createAlbum', () { + test('shared with assets', () async { + when( + () => albumApiRepository.create( + "name", + assetIds: any(named: "assetIds"), + sharedUserIds: any(named: "sharedUserIds"), + ), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => albumRepository.create(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.twoAsset); + + final result = + await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]); + expect(result, AlbumStub.twoAsset); + verify( + () => albumApiRepository.create( + "name", + assetIds: [AssetStub.image1.remoteId!], + sharedUserIds: [UserStub.user1.id], + ), + ).called(1); + verify( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).called(1); + }); + }); + + group('addAdditionalAssetToAlbum', () { + test('one added, one duplicate', () async { + when( + () => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()), + ).thenAnswer( + (_) async => ( + added: [AssetStub.image2.remoteId!], + duplicates: [AssetStub.image1.remoteId!] + ), + ); + when( + () => albumRepository.get(AlbumStub.oneAsset.id), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), + ).thenAnswer((_) async {}); + when( + () => albumRepository.removeAssets(AlbumStub.oneAsset, []), + ).thenAnswer((_) async {}); + when( + () => albumRepository.recalculateMetadata(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.update(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + final result = await sut.addAssets( + AlbumStub.oneAsset, + [AssetStub.image1, AssetStub.image2], + ); + + expect(result != null, true); + expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]); + expect(result.successfullyAdded, 1); + }); + }); + + group('addAdditionalUserToAlbum', () { + test('one added', () async { + when( + () => + albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()), + ).thenAnswer( + (_) async => AlbumStub.sharedWithUser, + ); + + when( + () => albumRepository.addUsers( + AlbumStub.emptyAlbum, + AlbumStub.emptyAlbum.sharedUsers.toList(), + ), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); + + when( + () => albumRepository.update(AlbumStub.emptyAlbum), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); + + final result = await sut.addUsers( + AlbumStub.emptyAlbum, + [UserStub.user2.id], + ); + + expect(result, true); + }); + }); +} diff --git a/mobile/test/services/entity.service_test.dart b/mobile/test/services/entity.service_test.dart new file mode 100644 index 0000000000..8c8b49a7e0 --- /dev/null +++ b/mobile/test/services/entity.service_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/services/entity.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../fixtures/asset.stub.dart'; +import '../fixtures/user.stub.dart'; +import '../repository.mocks.dart'; + +void main() { + late EntityService sut; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + + setUp(() { + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + sut = EntityService(assetRepository, userRepository); + }); + + group('fillAlbumWithDatabaseEntities', () { + test('remote album with owner, thumbnail, sharedUsers and assets', + () async { + final Album album = Album( + name: "album-with-two-assets-and-two-users", + localId: "album-with-two-assets-and-two-users-local", + remoteId: "album-with-two-assets-and-two-users-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: true, + activityEnabled: true, + startDate: DateTime(2019), + endDate: DateTime(2020), + ) + ..remoteThumbnailAssetId = AssetStub.image1.remoteId + ..assets.addAll([AssetStub.image1, AssetStub.image1]) + ..owner.value = UserStub.user1 + ..sharedUsers.addAll([UserStub.admin, UserStub.admin]); + + when(() => userRepository.get(album.ownerId!)) + .thenAnswer((_) async => UserStub.admin); + + when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)) + .thenAnswer((_) async => AssetStub.image1); + + when(() => userRepository.getByIds(any())) + .thenAnswer((_) async => [UserStub.user1, UserStub.user2]); + + when(() => assetRepository.getAllByRemoteId(any())) + .thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]); + + await sut.fillAlbumWithDatabaseEntities(album); + expect(album.owner.value, UserStub.admin); + expect(album.thumbnail.value, AssetStub.image1); + expect(album.remoteUsers.toSet(), {UserStub.user1, UserStub.user2}); + expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2}); + }); + + test('remote album without any info', () async { + makeEmptyAlbum() => Album( + name: "album-without-info", + localId: "album-without-info-local", + remoteId: "album-without-info-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: false, + activityEnabled: false, + ); + + final album = makeEmptyAlbum(); + await sut.fillAlbumWithDatabaseEntities(album); + verifyNoMoreInteractions(assetRepository); + verifyNoMoreInteractions(userRepository); + expect(album, makeEmptyAlbum()); + }); + }); +} diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index a00d57d0ae..bf8b24b557 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -OPENAPI_GENERATOR_VERSION=v7.5.0 +OPENAPI_GENERATOR_VERSION=v7.8.0 # usage: ./bin/generate-open-api.sh @@ -8,12 +8,14 @@ function dart { cd ./templates/mobile/serialization/native 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 =0.13.0 <2.0.0' + intl: any + meta: '>=1.1.8 <2.0.0' +-dev_dependencies: +- test: '>=1.21.6 <1.22.0' ++ immich_mobile: ++ path: ../ diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 254843e00e..9a7b1439b1 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -111,6 +111,7 @@ class {{{classname}}} { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static {{{classname}}}? fromJson(dynamic value) { + upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 02e07f933a..4ba6594966 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2023-08-31 23:09:59.584269162 +0200 -+++ native_class1.mustache 2023-08-31 22:59:53.633083270 +0200 +--- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 ++++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -17,10 +17,14 @@ } {{/defaultValue}} {{/required}} -@@ -114,17 +114,6 @@ +@@ -111,20 +111,10 @@ + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { ++ upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); - + - // Ensure that the map contains the required keys. - // Note 1: the values aren't checked for validity beyond being non-null. - // Note 2: this code is stripped in release mode! @@ -35,9 +39,9 @@ return {{{classname}}}( {{#vars}} {{#isDateTime}} -@@ -215,6 +204,10 @@ +@@ -215,6 +205,10 @@ ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), @@ -46,7 +50,7 @@ {{^isNumber}} {{^isEnum}} {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, -@@ -223,6 +216,7 @@ +@@ -223,6 +217,7 @@ {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isEnum}} {{/isNumber}} diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index dd9aa16f02..726c63da8e 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.119.1", "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": "^20.14.12", + "@types/node": "^22.8.1", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 85575893f0..2318155473 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.111.0 + * 1.119.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -19,6 +19,7 @@ export type UserResponseDto = { email: string; id: string; name: string; + profileChangedAt: string; profileImagePath: string; }; export type ActivityResponseDto = { @@ -53,6 +54,7 @@ export type UserAdminResponseDto = { license: (UserLicense) | null; name: string; oauthId: string; + profileChangedAt: string; profileImagePath: string; quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; @@ -86,50 +88,90 @@ export type AvatarResponse = { }; export type DownloadResponse = { archiveSize: number; + includeEmbeddedVideos: boolean; }; export type EmailNotificationsResponse = { albumInvite: boolean; albumUpdate: boolean; enabled: boolean; }; -export type MemoryResponse = { +export type FoldersResponse = { enabled: boolean; + sidebarWeb: boolean; +}; +export type MemoriesResponse = { + enabled: boolean; +}; +export type PeopleResponse = { + enabled: boolean; + sidebarWeb: boolean; }; export type PurchaseResponse = { hideBuyButtonUntil: string; showSupportBadge: boolean; }; +export type RatingsResponse = { + enabled: boolean; +}; +export type TagsResponse = { + enabled: boolean; + sidebarWeb: boolean; +}; export type UserPreferencesResponseDto = { avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; - memories: MemoryResponse; + folders: FoldersResponse; + memories: MemoriesResponse; + people: PeopleResponse; purchase: PurchaseResponse; + ratings: RatingsResponse; + tags: TagsResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; }; export type DownloadUpdate = { archiveSize?: number; + includeEmbeddedVideos?: boolean; }; export type EmailNotificationsUpdate = { albumInvite?: boolean; albumUpdate?: boolean; enabled?: boolean; }; -export type MemoryUpdate = { +export type FoldersUpdate = { enabled?: boolean; + sidebarWeb?: boolean; +}; +export type MemoriesUpdate = { + enabled?: boolean; +}; +export type PeopleUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; }; export type PurchaseUpdate = { hideBuyButtonUntil?: string; showSupportBadge?: boolean; }; +export type RatingsUpdate = { + enabled?: boolean; +}; +export type TagsUpdate = { + enabled?: boolean; + sidebarWeb?: boolean; +}; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; - memories?: MemoryUpdate; + folders?: FoldersUpdate; + memories?: MemoriesUpdate; + people?: PeopleUpdate; purchase?: PurchaseUpdate; + ratings?: RatingsUpdate; + tags?: TagsUpdate; }; export type AlbumUserResponseDto = { role: AlbumUserRole; @@ -155,6 +197,7 @@ export type ExifResponseDto = { modifyDate?: string | null; orientation?: string | null; projectionType?: string | null; + rating?: number | null; state?: string | null; timeZone?: string | null; }; @@ -166,6 +209,7 @@ export type AssetFaceWithoutPersonResponseDto = { id: string; imageHeight: number; imageWidth: number; + sourceType?: SourceType; }; export type PersonWithFacesResponseDto = { birthDate: string | null; @@ -181,11 +225,19 @@ export type SmartInfoResponseDto = { objects?: string[] | null; tags?: string[] | null; }; +export type AssetStackResponseDto = { + assetCount: number; + id: string; + primaryAssetId: string; +}; export type TagResponseDto = { + color?: string; + createdAt: string; id: string; name: string; - "type": TagTypeEnum; - userId: string; + parentId?: string; + updatedAt: string; + value: string; }; export type AssetResponseDto = { /** base64 encoded sha1 hash */ @@ -213,11 +265,10 @@ export type AssetResponseDto = { owner?: UserResponseDto; ownerId: string; people?: PersonWithFacesResponseDto[]; - resized: boolean; + /** This property was deprecated in v1.113.0 */ + resized?: boolean; smartInfo?: SmartInfoResponseDto; - stack?: AssetResponseDto[]; - stackCount: number | null; - stackParentId?: string | null; + stack?: (AssetStackResponseDto) | null; tags?: TagResponseDto[]; thumbhash: string | null; "type": AssetTypeEnum; @@ -254,7 +305,7 @@ export type CreateAlbumDto = { assetIds?: string[]; description?: string; }; -export type AlbumCountResponseDto = { +export type AlbumStatisticsResponseDto = { notShared: number; owned: number; shared: number; @@ -288,10 +339,12 @@ export type ApiKeyResponseDto = { createdAt: string; id: string; name: string; + permissions: Permission[]; updatedAt: string; }; export type ApiKeyCreateDto = { name?: string; + permissions: Permission[]; }; export type ApiKeyCreateResponseDto = { apiKey: ApiKeyResponseDto; @@ -313,7 +366,6 @@ export type AssetMediaCreateDto = { fileModifiedAt: string; isArchived?: boolean; isFavorite?: boolean; - isOffline?: boolean; isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; @@ -330,8 +382,7 @@ export type AssetBulkUpdateDto = { isFavorite?: boolean; latitude?: number; longitude?: number; - removeParent?: boolean; - stackParentId?: string; + rating?: number; }; export type AssetBulkUploadCheckItem = { /** base64 or hex encoded sha1 hash */ @@ -345,6 +396,7 @@ export type AssetBulkUploadCheckResult = { action: Action; assetId?: string; id: string; + isTrashed?: boolean; reason?: Reason; }; export type AssetBulkUploadCheckResponseDto = { @@ -365,10 +417,6 @@ export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; yearsAgo: number; }; -export type UpdateStackParentDto = { - newParentId: string; - oldParentId: string; -}; export type AssetStatsResponseDto = { images: number; total: number; @@ -380,7 +428,9 @@ export type UpdateAssetDto = { isArchived?: boolean; isFavorite?: boolean; latitude?: number; + livePhotoVideoId?: string | null; longitude?: number; + rating?: number; }; export type AssetMediaReplaceDto = { assetData: Blob; @@ -462,6 +512,7 @@ export type AssetFaceResponseDto = { imageHeight: number; imageWidth: number; person: (PersonResponseDto) | null; + sourceType?: SourceType; }; export type FaceDto = { id: string; @@ -484,6 +535,7 @@ export type JobStatusDto = { }; export type AllJobStatusResponseDto = { backgroundTask: JobStatusDto; + backupDatabase: JobStatusDto; duplicateDetection: JobStatusDto; faceDetection: JobStatusDto; facialRecognition: JobStatusDto; @@ -498,9 +550,12 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; - force: boolean; + force?: boolean; }; export type LibraryResponseDto = { assetCount: number; @@ -524,10 +579,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type ScanLibraryDto = { - refreshAllFiles?: boolean; - refreshModifiedFiles?: boolean; -}; export type LibraryStatsResponseDto = { photos: number; total: number; @@ -601,6 +652,9 @@ export type SystemConfigSmtpDto = { replyTo: string; transport: SystemConfigSmtpTransportDto; }; +export type TestEmailResponseDto = { + messageId: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -616,6 +670,7 @@ export type PartnerResponseDto = { id: string; inTimeline?: boolean; name: string; + profileChangedAt: string; profileImagePath: string; }; export type UpdatePartnerDto = { @@ -708,8 +763,8 @@ export type SearchExploreResponseDto = { }; export type MetadataSearchDto = { checksum?: string; - city?: string; - country?: string; + city?: string | null; + country?: string | null; createdAfter?: string; createdBefore?: string; deviceAssetId?: string; @@ -723,10 +778,10 @@ export type MetadataSearchDto = { isNotInAlbum?: boolean; isOffline?: boolean; isVisible?: boolean; - lensModel?: string; + lensModel?: string | null; libraryId?: string | null; make?: string; - model?: string; + model?: string | null; order?: AssetOrder; originalFileName?: string; originalPath?: string; @@ -734,7 +789,7 @@ export type MetadataSearchDto = { personIds?: string[]; previewPath?: string; size?: number; - state?: string; + state?: string | null; takenAfter?: string; takenBefore?: string; thumbnailPath?: string; @@ -781,9 +836,9 @@ export type PlacesResponseDto = { longitude: number; name: string; }; -export type SmartSearchDto = { - city?: string; - country?: string; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; createdAfter?: string; createdBefore?: string; deviceId?: string; @@ -794,15 +849,48 @@ export type SmartSearchDto = { isNotInAlbum?: boolean; isOffline?: boolean; isVisible?: boolean; - lensModel?: string; + lensModel?: string | null; libraryId?: string | null; make?: string; - model?: string; + model?: string | null; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; +export type SmartSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; page?: number; personIds?: string[]; query: string; size?: number; - state?: string; + state?: string | null; takenAfter?: string; takenBefore?: string; trashedAfter?: string; @@ -830,6 +918,10 @@ export type ServerAboutResponseDto = { sourceCommit?: string; sourceRef?: string; sourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySourceUrl?: string; + thirdPartySupportUrl?: string; version: string; versionUrl: string; }; @@ -838,6 +930,8 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + mapDarkStyleUrl: string; + mapLightStyleUrl: string; oauthButtonText: string; trashDays: number; userDeleteDelay: number; @@ -847,6 +941,7 @@ export type ServerFeaturesDto = { duplicateDetection: boolean; email: boolean; facialRecognition: boolean; + importFaces: boolean; map: boolean; oauth: boolean; oauthAutoLaunch: boolean; @@ -857,6 +952,15 @@ export type ServerFeaturesDto = { smartSearch: boolean; trash: boolean; }; +export type LicenseResponseDto = { + activatedAt: string; + activationKey: string; + licenseKey: string; +}; +export type LicenseKeyDto = { + activationKey: string; + licenseKey: string; +}; export type ServerMediaTypesResponseDto = { image: string[]; sidecar: string[]; @@ -897,14 +1001,10 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; -export type LicenseResponseDto = { - activatedAt: string; - activationKey: string; - licenseKey: string; -}; -export type LicenseKeyDto = { - activationKey: string; - licenseKey: string; +export type ServerVersionHistoryResponseDto = { + createdAt: string; + id: string; + version: string; }; export type SessionResponseDto = { createdAt: string; @@ -958,6 +1058,18 @@ export type AssetIdsResponseDto = { error?: Error2; success: boolean; }; +export type StackResponseDto = { + assets: AssetResponseDto[]; + id: string; + primaryAssetId: string; +}; +export type StackCreateDto = { + /** first asset becomes the primary */ + assetIds: string[]; +}; +export type StackUpdateDto = { + primaryAssetId?: string; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -973,6 +1085,14 @@ export type AssetFullSyncDto = { updatedUntil: string; userId?: string; }; +export type DatabaseBackupConfig = { + cronExpression: string; + enabled: boolean; + keepLastAmount: number; +}; +export type SystemConfigBackupsDto = { + database: DatabaseBackupConfig; +}; export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; accelDecode: boolean; @@ -997,14 +1117,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; @@ -1064,6 +1186,12 @@ export type SystemConfigMapDto = { enabled: boolean; lightStyle: string; }; +export type SystemConfigFacesDto = { + "import": boolean; +}; +export type SystemConfigMetadataDto = { + faces: SystemConfigFacesDto; +}; export type SystemConfigNewVersionCheckDto = { enabled: boolean; }; @@ -1113,6 +1241,7 @@ export type SystemConfigUserDto = { deleteDelay: number; }; export type SystemConfigDto = { + backup: SystemConfigBackupsDto; ffmpeg: SystemConfigFFmpegDto; image: SystemConfigImageDto; job: SystemConfigJobDto; @@ -1120,6 +1249,7 @@ export type SystemConfigDto = { logging: SystemConfigLoggingDto; machineLearning: SystemConfigMachineLearningDto; map: SystemConfigMapDto; + metadata: SystemConfigMetadataDto; newVersionCheck: SystemConfigNewVersionCheckDto; notifications: SystemConfigNotificationsDto; oauth: SystemConfigOAuthDto; @@ -1148,17 +1278,31 @@ export type ReverseGeocodingStateResponseDto = { lastImportFileName: string | null; lastUpdate: string | null; }; -export type CreateTagDto = { +export type TagCreateDto = { + color?: string; name: string; - "type": TagTypeEnum; + parentId?: string | null; }; -export type UpdateTagDto = { - name?: string; +export type TagUpsertDto = { + tags: string[]; +}; +export type TagBulkAssetsDto = { + assetIds: string[]; + tagIds: string[]; +}; +export type TagBulkAssetsResponseDto = { + count: number; +}; +export type TagUpdateDto = { + color?: string | null; }; export type TimeBucketResponseDto = { count: number; timeBucket: string; }; +export type TrashResponseDto = { + count: number; +}; export type UserUpdateMeDto = { email?: string; name?: string; @@ -1168,6 +1312,7 @@ export type CreateProfileImageDto = { file: Blob; }; export type CreateProfileImageResponseDto = { + profileChangedAt: string; profileImagePath: string; userId: string; }; @@ -1345,11 +1490,11 @@ export function createAlbum({ createAlbumDto }: { body: createAlbumDto }))); } -export function getAlbumCount(opts?: Oazapfts.RequestOpts) { +export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AlbumCountResponseDto; - }>("/albums/count", { + data: AlbumStatisticsResponseDto; + }>("/albums/statistics", { ...opts })); } @@ -1605,6 +1750,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -1617,15 +1765,6 @@ export function getRandom({ count }: { ...opts })); } -export function updateStackParent({ updateStackParentDto }: { - updateStackParentDto: UpdateStackParentDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/assets/stack/parent", oazapfts.json({ - ...opts, - method: "PUT", - body: updateStackParentDto - }))); -} export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: { isArchived?: boolean; isFavorite?: boolean; @@ -1869,6 +2008,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -1933,24 +2081,14 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } -export function removeOfflineFiles({ id }: { +export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, { ...opts, method: "POST" })); } -export function scanLibrary({ id, scanLibraryDto }: { - id: string; - scanLibraryDto: ScanLibraryDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ - ...opts, - method: "POST", - body: scanLibraryDto - }))); -} export function getLibraryStatistics({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -2010,20 +2148,6 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2104,7 +2228,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: { export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/notifications/test-email", oazapfts.json({ ...opts, method: "POST", body: systemConfigSmtpDto @@ -2153,7 +2280,7 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) { } export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: UserAdminResponseDto; }>("/oauth/unlink", { ...opts, @@ -2267,16 +2394,6 @@ export function updatePerson({ id, personUpdateDto }: { body: personUpdateDto }))); } -export function getPersonAssets({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>(`/people/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} export function mergePerson({ id, mergePersonDto }: { id: string; mergePersonDto: MergePersonDto; @@ -2406,6 +2523,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { @@ -2418,8 +2547,9 @@ export function searchSmart({ smartSearchDto }: { body: smartSearchDto }))); } -export function getSearchSuggestions({ country, make, model, state, $type }: { +export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { country?: string; + includeNull?: boolean; make?: string; model?: string; state?: string; @@ -2430,6 +2560,7 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { data: string[]; }>(`/search/suggestions${QS.query(QS.explode({ country, + includeNull, make, model, state, @@ -2438,102 +2569,27 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getAboutInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerAboutResponseDto; - }>("/server-info/about", { + }>("/server/about", { ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerConfigDto; - }>("/server-info/config", { + }>("/server/config", { ...opts })); } -/** - * This property was deprecated in v1.107.0 - */ export function getServerFeatures(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ServerFeaturesDto; - }>("/server-info/features", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerMediaTypesResponseDto; - }>("/server-info/media-types", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function pingServer(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerPingResponseRead; - }>("/server-info/ping", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getServerStatistics(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerStatsResponseDto; - }>("/server-info/statistics", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getStorage(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerStorageResponseDto; - }>("/server-info/storage", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getTheme(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerThemeDto; - }>("/server-info/theme", { - ...opts - })); -} -/** - * This property was deprecated in v1.107.0 - */ -export function getServerVersion(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerVersionResponseDto; - }>("/server-info/version", { + }>("/server/features", { ...opts })); } @@ -2565,6 +2621,62 @@ export function setServerLicense({ licenseKeyDto }: { body: licenseKeyDto }))); } +export function getSupportedMediaTypes(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerMediaTypesResponseDto; + }>("/server/media-types", { + ...opts + })); +} +export function pingServer(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerPingResponseRead; + }>("/server/ping", { + ...opts + })); +} +export function getServerStatistics(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerStatsResponseDto; + }>("/server/statistics", { + ...opts + })); +} +export function getStorage(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerStorageResponseDto; + }>("/server/storage", { + ...opts + })); +} +export function getTheme(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerThemeDto; + }>("/server/theme", { + ...opts + })); +} +export function getServerVersion(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerVersionResponseDto; + }>("/server/version", { + ...opts + })); +} +export function getVersionHistory(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerVersionHistoryResponseDto[]; + }>("/server/version-history", { + ...opts + })); +} export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/sessions", { ...opts, @@ -2686,6 +2798,70 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: { body: assetIdsDto }))); } +export function deleteStacks({ bulkIdsDto }: { + bulkIdsDto: BulkIdsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/stacks", oazapfts.json({ + ...opts, + method: "DELETE", + body: bulkIdsDto + }))); +} +export function searchStacks({ primaryAssetId }: { + primaryAssetId?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto[]; + }>(`/stacks${QS.query(QS.explode({ + primaryAssetId + }))}`, { + ...opts + })); +} +export function createStack({ stackCreateDto }: { + stackCreateDto: StackCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: StackResponseDto; + }>("/stacks", oazapfts.json({ + ...opts, + method: "POST", + body: stackCreateDto + }))); +} +export function deleteStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/stacks/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getStack({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateStack({ id, stackUpdateDto }: { + id: string; + stackUpdateDto: StackUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: StackResponseDto; + }>(`/stacks/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: stackUpdateDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -2779,8 +2955,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function createTag({ createTagDto }: { - createTagDto: CreateTagDto; +export function createTag({ tagCreateDto }: { + tagCreateDto: TagCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; @@ -2788,7 +2964,31 @@ export function createTag({ createTagDto }: { }>("/tags", oazapfts.json({ ...opts, method: "POST", - body: createTagDto + body: tagCreateDto + }))); +} +export function upsertTags({ tagUpsertDto }: { + tagUpsertDto: TagUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagResponseDto[]; + }>("/tags", oazapfts.json({ + ...opts, + method: "PUT", + body: tagUpsertDto + }))); +} +export function bulkTagAssets({ tagBulkAssetsDto }: { + tagBulkAssetsDto: TagBulkAssetsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagBulkAssetsResponseDto; + }>("/tags/assets", oazapfts.json({ + ...opts, + method: "PUT", + body: tagBulkAssetsDto }))); } export function deleteTag({ id }: { @@ -2809,56 +3009,46 @@ export function getTagById({ id }: { ...opts })); } -export function updateTag({ id, updateTagDto }: { +export function updateTag({ id, tagUpdateDto }: { id: string; - updateTagDto: UpdateTagDto; + tagUpdateDto: TagUpdateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "PATCH", - body: updateTagDto + method: "PUT", + body: tagUpdateDto }))); } -export function untagAssets({ id, assetIdsDto }: { +export function untagAssets({ id, bulkIdsDto }: { id: string; - assetIdsDto: AssetIdsDto; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTagAssets({ id }: { +export function tagAssets({ id, bulkIdsDto }: { id: string; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; - }>(`/tags/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} -export function tagAssets({ id, assetIdsDto }: { - id: string; - assetIdsDto: AssetIdsDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "PUT", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2867,6 +3057,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; timeBucket: string; userId?: string; withPartners?: boolean; @@ -2884,6 +3075,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, + tagId, timeBucket, userId, withPartners, @@ -2892,7 +3084,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2901,6 +3093,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; userId?: string; withPartners?: boolean; withStacked?: boolean; @@ -2917,6 +3110,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order, personId, size, + tagId, userId, withPartners, withStacked @@ -2925,13 +3119,19 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key })); } export function emptyTrash(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/empty", { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/empty", { ...opts, method: "POST" })); } export function restoreTrash(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/restore", { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/restore", { ...opts, method: "POST" })); @@ -2939,7 +3139,10 @@ export function restoreTrash(opts?: Oazapfts.RequestOpts) { export function restoreAssets({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/restore/assets", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/restore/assets", oazapfts.json({ ...opts, method: "POST", body: bulkIdsDto @@ -3057,6 +3260,26 @@ export function getProfileImage({ id }: { ...opts })); } +export function getAssetsByOriginalPath({ path }: { + path: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/view/folder${QS.query(QS.explode({ + path + }))}`, { + ...opts + })); +} +export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: string[]; + }>("/view/folder/unique-paths", { + ...opts + })); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -3086,10 +3309,9 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } -export enum TagTypeEnum { - Object = "OBJECT", - Face = "FACE", - Custom = "CUSTOM" +export enum SourceType { + MachineLearning = "machine-learning", + Exif = "exif" } export enum AssetTypeEnum { Image = "IMAGE", @@ -3107,6 +3329,86 @@ export enum Error { NotFound = "not_found", Unknown = "unknown" } +export enum Permission { + All = "all", + ActivityCreate = "activity.create", + ActivityRead = "activity.read", + ActivityUpdate = "activity.update", + ActivityDelete = "activity.delete", + ActivityStatistics = "activity.statistics", + ApiKeyCreate = "apiKey.create", + ApiKeyRead = "apiKey.read", + ApiKeyUpdate = "apiKey.update", + ApiKeyDelete = "apiKey.delete", + AssetRead = "asset.read", + AssetUpdate = "asset.update", + AssetDelete = "asset.delete", + AssetShare = "asset.share", + AssetView = "asset.view", + AssetDownload = "asset.download", + AssetUpload = "asset.upload", + AlbumCreate = "album.create", + AlbumRead = "album.read", + AlbumUpdate = "album.update", + AlbumDelete = "album.delete", + AlbumStatistics = "album.statistics", + AlbumAddAsset = "album.addAsset", + AlbumRemoveAsset = "album.removeAsset", + AlbumShare = "album.share", + AlbumDownload = "album.download", + AuthDeviceDelete = "authDevice.delete", + ArchiveRead = "archive.read", + FaceCreate = "face.create", + FaceRead = "face.read", + FaceUpdate = "face.update", + FaceDelete = "face.delete", + LibraryCreate = "library.create", + LibraryRead = "library.read", + LibraryUpdate = "library.update", + LibraryDelete = "library.delete", + LibraryStatistics = "library.statistics", + TimelineRead = "timeline.read", + TimelineDownload = "timeline.download", + MemoryCreate = "memory.create", + MemoryRead = "memory.read", + MemoryUpdate = "memory.update", + MemoryDelete = "memory.delete", + PartnerCreate = "partner.create", + PartnerRead = "partner.read", + PartnerUpdate = "partner.update", + PartnerDelete = "partner.delete", + PersonCreate = "person.create", + PersonRead = "person.read", + PersonUpdate = "person.update", + PersonDelete = "person.delete", + PersonStatistics = "person.statistics", + PersonMerge = "person.merge", + PersonReassign = "person.reassign", + SessionRead = "session.read", + SessionUpdate = "session.update", + SessionDelete = "session.delete", + SharedLinkCreate = "sharedLink.create", + SharedLinkRead = "sharedLink.read", + SharedLinkUpdate = "sharedLink.update", + SharedLinkDelete = "sharedLink.delete", + StackCreate = "stack.create", + StackRead = "stack.read", + StackUpdate = "stack.update", + StackDelete = "stack.delete", + SystemConfigRead = "systemConfig.read", + SystemConfigUpdate = "systemConfig.update", + SystemMetadataRead = "systemMetadata.read", + SystemMetadataUpdate = "systemMetadata.update", + TagCreate = "tag.create", + TagRead = "tag.read", + TagUpdate = "tag.update", + TagDelete = "tag.delete", + TagAsset = "tag.asset", + AdminUserCreate = "admin.user.create", + AdminUserRead = "admin.user.read", + AdminUserUpdate = "admin.user.update", + AdminUserDelete = "admin.user.delete" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", @@ -3121,8 +3423,9 @@ export enum Reason { UnsupportedFormat = "unsupported-format" } export enum AssetJobName { - RegenerateThumbnail = "regenerate-thumbnail", + RefreshFaces = "refresh-faces", RefreshMetadata = "refresh-metadata", + RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } export enum AssetMediaSize { @@ -3133,6 +3436,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", @@ -3147,7 +3455,8 @@ export enum JobName { Search = "search", Sidecar = "sidecar", Library = "library", - Notifications = "notifications" + Notifications = "notifications", + BackupDatabase = "backupDatabase" } export enum JobCommand { Start = "start", @@ -3156,10 +3465,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum MemoryType { OnThisDay = "on_this_day" } @@ -3207,7 +3512,8 @@ export enum TranscodeHWAccel { export enum AudioCodec { Mp3 = "mp3", Aac = "aac", - Libopus = "libopus" + Libopus = "libopus", + PcmS16Le = "pcm_s16le" } export enum VideoContainer { Mov = "mov", diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index 7df39d226b..8fa4ac1195 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -32,6 +32,7 @@ Русский Português Brasileiro Svenska + ภาษาไทย

## تنصل diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index ed14649e0a..66a8b584fd 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Avís legal diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 7a59e3444e..70ad472aa1 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -32,6 +32,8 @@ Português Brasileiro Svenska العربية + Tiếng Việt + ภาษาไทย

## Warnung @@ -101,6 +103,8 @@ Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint | Offline Unterstützung | Ja | Nein | | Schreibgeschützte Gallerie | Ja | Ja | | Gestapelte Bilder | Ja | Ja | +| Tags | Nein | Ja | +| Ordner-Ansicht | Nein | Ja | ## Übersetzungen diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index 726a504526..0b0dbf919d 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Advertencia diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index da52fe28a6..e2f979d254 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Clause de non-responsabilité diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 1523143f06..7208df7e24 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Declino di responsabilità diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 98ff8e68d9..828afa9812 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## 免責事項 diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index 66df040d75..8b280e0a9b 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -33,6 +33,7 @@ Português Brasileiro Svenska العربية +ภาษาไทย

diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index 1c877d9d3e..e1cf6d66f5 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Disclaimer diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index d872b8435b..5468ebb4c4 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -1,84 +1,91 @@ -

-
+

+
Licença: AGPLv3 Discord -
-
+
+

- +

-

Immich - Solução self-hosted de alta performance para backup de fotos e vídeos

+

Solução self-hosted de alta performance para backup de fotos e vídeos


- +

- English - Català - Español - Français - Italiano - 日本語 - 한국어 - Deutsch - Nederlands - Türkçe - 中文 - Русский - Svenska - العربية + +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Svenska +العربية +Tiếng Việt +ภาษาไทย +

## Avisos - ⚠️ Este projeto está sob **desenvolvimento constante**. -- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a compatibilidade com versões anteriores). -- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e vídeos.** -- ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas! +- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a + compatibilidade com versões anteriores). +- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e + vídeos.** +- ⚠️ Sempre siga o plano + [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup + para as suas mídias preciosas! -## Conteúdo +> [!NOTE] +> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. -- [Documentação Oficial](https://immich.app/docs) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) -- [Demonstração](#demo) -- [Recursos](#features) -- [Introdução](https://immich.app/docs/overview/introduction) +## Links + +- [Documentação](https://immich.app/docs) +- [Sobre](https://immich.app/docs/overview/introduction) - [Instalação](https://immich.app/docs/install/requirements) +- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Demonstração](#demonstração) +- [Funcionalidades](#funcionalidades) +- [Traduções](https://immich.app/docs/developer/translations) - [Diretrizes de Contribuição](https://immich.app/docs/overview/support-the-project) -## Documentação - -Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. - ## Demonstração -Você pode acessar a demonstração web em https://demo.immich.app +Acesse a demonstração [aqui](https://demo.immich.app). A demonstração está +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` +No aplicativo para dispositivos móveis, você pode usar +`https://demo.immich.app/api` no campo `Server Endpoint URL` -```bash title="Credenciais de Demonstração" -Credenciais de Demonstração -email: demo@immich.app -senha: demo -``` +### Credenciais de login -``` -Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` +| Email | Senha | +| --------------- | ----- | +| demo@immich.app | demo | ## Atividades + ![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats") -## Recursos +## Funcionalidades - -| Recursos | Aplicativo Móvel | Web | -|:----------------------------------------------------|------------------|-----| +| Funcionalidades | Aplicativo Móvel | Web | +| :-------------------------------------------------- | ---------------- | --- | | Fazer upload e visualizar fotos e vídeos | Sim | Sim | | Backup automático ao abrir o aplicativo | Sim | N/A | | Prevenir a duplicação de arquivos | Sim | Sim | @@ -88,17 +95,17 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Criação de álbuns e álbuns compartilhados | Sim | Sim | | Barra de rolagem arrastável | Sim | Sim | | Suporta formatos RAW | Sim | Sim | -| Visualização de metadados (EXIF, map) | Sim | Sim | -| Pesquisar por metadados, objetos, rostos, and CLIP | Sim | Sim | +| Visualização de metadados (EXIF, mapa) | Sim | Sim | +| Pesquisar por metadados, objetos, rostos, e CLIP | Sim | Sim | | Funções administrativas (gerenciamento de usuários) | Não | Sim | | Backup em segundo plano | Sim | N/A | -| Virtual scroll | Sim | Sim | +| Rolagem virtual | Sim | Sim | | Suporte OAuth | Sim | Sim | | Chaves de API | N/A | Sim | -| Backup e visualização de LivePhoto/MotionPhoto | Sim | Sim | +| Backup e reprodução de LivePhoto/MotionPhoto | Sim | Sim | | Visualização de imagens 360º | Não | Sim | | Estrutura de armazenamento definida pelo usuário | Sim | Sim | -| Compartilhar com o público | Não | Sim | +| Compartilhar com o público | Sim | Sim | | Arquivo e Favoritos | Sim | Sim | | Mapa Global | Sim | Sim | | Compartilhamento com parceiro | Sim | Sim | @@ -108,6 +115,29 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Galeria em modo apenas leitura | Sim | Sim | | Empilhamento de fotos | Sim | Sim | +## Traduções + +Leia mais sobre as traduções +[aqui](https://immich.app/docs/developer/translations). + + +Status da tradução + + +## Atividade do repositório + +![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de análise de atividade Repobeats") + +## Histórico de estrelas + + + + + + Gráfico de histórico de estrelas + + + ## Contribuidores diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 11a2a34f33..0ff3e3f08f 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Предупреждение diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index 3673eab57c..29706acb55 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -33,6 +33,7 @@ Русский Português Brasileiro العربية + ภาษาไทย

## Ansvarsfriskrivning diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md new file mode 100644 index 0000000000..6a6b70d435 --- /dev/null +++ b/readme_i18n/README_th_TH.md @@ -0,0 +1,134 @@ +

+
+ License: AGPLv3 + + Discord + +
+
+

+ +

+ +

+ +

โซลูชันการจัดการภาพถ่ายและวิดีโอแบบโฮสต์เองที่มีประสิทธิภาพสูง

+
+ + + + +
+ +

+ English + Català + Español + Français + Italiano + 日本語 + 한국어 + Deutsch + Nederlands + Türkçe + 中文 + Русский + Português Brasileiro + Svenska + العربية + Tiếng Việt +

+ +## ข้อจำกัดความรับผิดชอบ + +- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก** +- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย +- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** +- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ! + +> [!NOTE] +> คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/ + +## ลิงก์ + +- [คู่มือ](https://immich.app/docs) +- [เกี่ยวกับ](https://immich.app/docs/overview/introduction) +- [การติดตั้ง](https://immich.app/docs/install/requirements) +- [โรดแมป](https://immich.app/roadmap) +- [สาธิต](#สาธิต) +- [คุณสมบัติ](#คุณสมบัติ) +- [การแปลภาษา](https://immich.app/docs/developer/translations) +- [สนับสนุนโพรเจกต์](https://immich.app/docs/overview/support-the-project) + +## สาธิต + +เข้าถึงการสาธิตได้ [ที่นี่](https://demo.immich.app) โดยการสาธิตนี้ทำงานบน Oracle VM Free-tier ตั้งอยู่ที่อัมสเตอร์ดัม ใช้ซีพียู ARM64 quad-core 2.4Ghz และแรม 24GB + +สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app/api` เป็น `Server Endpoint URL` + +### ข้อมูลการเข้าสู่ระบบ + +| อีเมล | รหัสผ่าน | +| --------------- | -------- | +| demo@immich.app | demo | + +## คุณสมบัติ + +| คุณสมบัติ | มือถือ | เว็บ | +| :----------------------------------------- | ------ | ------ | +| อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ | +| การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A | +| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ | +| เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A | +| ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ | +| รองรับผู้ใช้หลายคน | ใช่ | ใช่ | +| อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ | +| แถบเลื่อนแบบลากได้ | ใช่ | ใช่ | +| รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ | +| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ | +| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | +| ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ | +| การสำรองข้อมูลพื้นหลัง | ใช่ | N/A | +| การเลื่อนแบบเสมือน | ใช่ | ใช่ | +| รองรับ OAuth | ใช่ | ใช่ | +| คีย์ API | N/A | ใช่ | +| การสำรองและเล่น LivePhoto/MotionPhoto | ใช่ | ใช่ | +| รองรับการแสดงภาพ 360 องศา | ไม่ใช่ | ใช่ | +| โครงสร้างการจัดเก็บข้อมูลที่ผู้ใช้กำหนดเอง | ใช่ | ใช่ | +| การแชร์สาธารณะ | ใช่ | ใช่ | +| การจัดเก็บและรายการโปรด | ใช่ | ใช่ | +| แผนที่ทั่วโลก | ใช่ | ใช่ | +| การแชร์กับคู่หู | ใช่ | ใช่ | +| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | +| ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ | +| รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ | +| แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ | +| ภาพถ่ายซ้อนกัน | ใช่ | ใช่ | + +## การแปลภาษา + +อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations) + + + สถานะการแปล + + +## กิจกรรมของคลังเก็บข้อมูล + +![กิจกรรม](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 f95d914880..6bf23be5f8 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Feragatname diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md new file mode 100644 index 0000000000..69d7a151be --- /dev/null +++ b/readme_i18n/README_vi_VN.md @@ -0,0 +1,134 @@ +

+
+ Giấy phép: AGPLv3 + + Discord + +
+
+

+ +

+ +

+

Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao

+
+ + + +
+

+ +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Português Brasileiro +Svenska +العربية +Tiếng Việt +ภาษาไทย + +

+ +## Tuyên bố miễn trừ trách nhiệm + +- ⚠️ Dự án đang được phát triển **rất tích cực**. +- ⚠️ Dự kiến ​​sẽ có lỗi và thay đổi đột ngột. +- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.** +- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn! + +> [!NOTE] +> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/. + +## Liên kết + +- [Tài liệu](https://immich.app/docs) +- [Giới thiệu](https://immich.app/docs/overview/introduction) +- [Cài đặt](https://immich.app/docs/install/requirements) +- [Lộ trình](https://immich.app/roadmap) +- [Demo](#demo) +- [Tính năng](#Tính-năng) +- [Dịch thuật](https://immich.app/docs/developer/translations) +- [Đóng góp](https://immich.app/docs/overview/support-the-project) + +## Demo + +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` + +### Thông tin đăng nhập + +| Email | Mật khẩu | +| --------------- | -------- | +| demo@immich.app | demo | + +## Tính năng + +| Tính năng | Mobile | Web | +| :--------------------------------------------------- | ------ | ----- | +| Tải lên và xem video, ảnh | Có | Có | +| Tự động sao lưu khi ứng dụng được mở | Có | N/A | +| Ngăn chặn sự trùng lặp nội dung | Có | Có | +| Album được chọn để sao lưu | Có | N/A | +| Tải ảnh và video xuống thiết bị cục bộ | Có | Có | +| Hỗ trợ nhiều người dùng | Có | Có | +| Album và Album được chia sẻ | Có | Có | +| Thanh cuộn có thể chà / kéo | Có | Có | +| Hỗ trợ định dạng raw | Có | Có | +| Xem metadata (EXIF, bản đồ) | Có | Có | +| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có | +| Chức năng quản trị (quản lý người dùng) | Không | Có | +| Sao lưu trong nền | Có | N/A | +| Cuộn ảo | Có | Có | +| Hỗ trợ OAuth | Có | Có | +| API Keys | N/A | Có | +| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có | +| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có | +| Cấu trúc lưu trữ do người dùng xác định | Có | Có | +| Chia sẻ công khai | Có | Có | +| Lưu trữ và Yêu thích | Có | Có | +| Bản đồ toàn cầu | Có | Có | +| Chia sẻ đối tác | Có | Có | +| Nhận dạng khuôn mặt và phân cụm | Có | Có | +| Kỷ niệm (x năm trước) | Có | Có | +| Hỗ trợ ngoại tuyến | Có | Không | +| Thư viện chỉ đọc | Có | Có | +| Ảnh xếp chồng | Có | Có | + +## Dịch thuật + +Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations). + + +Tình trạng dịch thuật + + +## Hoạt động của repository + +![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats") + +## Lịch sử Đánh dấu sao + + + + + + Biểu đồ Lịch sử Đánh dấu + + + +## Người đóng góp + + + + diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 71a9b8a4d8..380dc25992 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -36,7 +36,8 @@ Português Brasileiro Svenska العربية - + ภาษาไทย +

## 免责声明 @@ -47,7 +48,7 @@ - ⚠️ 为了您宝贵的照片与视频,请始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案! > [!NOTE] -> 完整的项目文档以及安装教程请参见:https://immich.app/。 +> 完整的项目文档以及安装教程请参见:。 ## 目录 @@ -57,7 +58,7 @@ - [路线图](https://immich.app/roadmap) - [在线演示](#示例) - [功能特性](#功能特性) -- [多语言](https://immich.app/docs/developer/tranlations) +- [多语言](https://immich.app/docs/developer/translations) - [贡献者](https://immich.app/docs/overview/support-the-project) ## 示例 @@ -95,7 +96,7 @@ | 实况照片备份和查看 | 是 | 是 | | 支持360度全景图显示 | 否 | 是 | | 用户自定义存储结构 | 是 | 是 | -| 公共分享 | 否 | 是 | +| 公共分享 | 是 | 是 | | 归档与收藏功能 | 是 | 是 | | 足迹地图 | 是 | 是 | | 好友分享 | 是 | 是 | diff --git a/renovate.json b/renovate.json index c15aded006..39e0e7f811 100644 --- a/renovate.json +++ b/renovate.json @@ -15,7 +15,7 @@ "groupName": "typescript-projects", "matchUpdateTypes": ["minor", "patch"], "excludePackagePrefixes": ["exiftool", "reflect-metadata"], - "excludePackageNames": ["node", "@types/node"], + "excludePackageNames": ["node", "@types/node", "@mapbox/mapbox-gl-rtl-text"], "schedule": "on tuesday" }, { @@ -79,7 +79,11 @@ "schedule": "on tuesday" } ], - "ignorePaths": ["mobile/openapi/pubspec.yaml", "mobile/ios", "mobile/android"], + "ignorePaths": [ + "mobile/openapi/pubspec.yaml", + "mobile/ios", + "mobile/android" + ], "ignoreDeps": ["http", "intl"], - "labels": ["dependencies", "renovate"] + "labels": ["dependencies", "changelog:skip"] } diff --git a/server/.eslintrc.js b/server/.eslintrc.js deleted file mode 100644 index 243f1b11e0..0000000000 --- a/server/.eslintrc.js +++ /dev/null @@ -1,39 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - tsconfigRootDir: __dirname, - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:unicorn/recommended'], - root: true, - env: { - node: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/filename-case': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/prefer-event-target': 'off', - 'unicorn/no-thenable': 'off', - 'unicorn/import-style': 'off', - 'unicorn/prefer-structured-clone': 'off', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - // Note: you must disable the base rule as it can report incorrect errors - 'require-await': 'off', - '@typescript-eslint/require-await': 'error', - curly: 2, - 'prettier/prettier': 0, - 'no-restricted-imports': ['error', { patterns: [{ group: ['.*'], message: 'Relative imports are not allowed.' }] }], - }, -}; diff --git a/server/.nvmrc b/server/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/server/Dockerfile b/server/Dockerfile index 81b0ea67d6..f14178dd9f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240730@sha256:3e03f236d7669d0b27fbd49bc617df69fbb719cec2310a1c7ed8291236648c22 as dev +FROM ghcr.io/immich-app/base-server-dev:20241029@sha256:31c2d6cba42253680ff7738dffa66e97dfe5b1c7faf23b29d07ab456e9c1e2b9 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:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 as web +FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ @@ -37,11 +37,12 @@ WORKDIR /usr/src/app COPY web/package*.json web/svelte.config.js ./ RUN npm ci COPY web ./ +COPY i18n ../i18n RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240730@sha256:40efde970c4dfb1ace5a10211b8ca1b5f04bff5da4b7537c9f76a0454a05f47d +FROM ghcr.io/immich-app/base-server-prod:20241029@sha256:669cc57091e3d2924b9e3001acb4998ed9c9585370017adcdf7beed21d249aae WORKDIR /usr/src/app ENV NODE_ENV=production \ @@ -76,7 +77,7 @@ ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} VOLUME /usr/src/app/upload -EXPOSE 3001 +EXPOSE 2283 ENTRYPOINT ["tini", "--", "/bin/bash"] CMD ["start.sh"] diff --git a/server/bin/immich-healthcheck b/server/bin/immich-healthcheck index 6043e526aa..cf0accb8dd 100755 --- a/server/bin/immich-healthcheck +++ b/server/bin/immich-healthcheck @@ -1,3 +1,3 @@ #!/usr/bin/env bash -node /usr/src/app/dist/utils/healthcheck.js +node /usr/src/app/dist/bin/healthcheck.js diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs new file mode 100644 index 0000000000..d29b6f7238 --- /dev/null +++ b/server/eslint.config.mjs @@ -0,0 +1,81 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 [ + { + ignores: ['eslint.config.mjs'], + }, + ...compat.extends( + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: 5, + sourceType: 'module', + + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, + + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/prefer-event-target': 'off', + 'unicorn/no-thenable': 'off', + 'unicorn/import-style': 'off', + 'unicorn/prefer-structured-clone': 'off', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', + 'require-await': 'off', + '@typescript-eslint/require-await': 'error', + curly: 2, + 'prettier/prettier': 0, + 'object-shorthand': ['error', 'always'], + + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['.*'], + message: 'Relative imports are not allowed.', + }, + ], + }, + ], + }, + }, +]; diff --git a/server/package-lock.json b/server/package-lock.json index a778fa6a8c..eb428e2eac 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,17 +1,16 @@ { "name": "immich", - "version": "1.111.0", + "version": "1.119.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.111.0", + "version": "1.119.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", "@nestjs/common": "^10.2.2", - "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.2.2", "@nestjs/event-emitter": "^2.0.4", "@nestjs/platform-express": "^10.2.2", @@ -20,11 +19,11 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.48.0", + "@opentelemetry/auto-instrumentations-node": "^0.52.0", "@opentelemetry/context-async-hooks": "^1.24.0", - "@opentelemetry/exporter-prometheus": "^0.52.0", - "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@opentelemetry/exporter-prometheus": "^0.54.0", + "@opentelemetry/sdk-node": "^0.54.0", + "@react-email/components": "^0.0.25", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -34,7 +33,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "^28.1.0", + "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -45,27 +44,30 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "picomatch": "^4.0.2", + "react": "^18.3.1", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "semver": "^7.6.2", "sharp": "^0.33.0", - "sirv": "^2.0.4", + "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": "^1.0.35", + "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", @@ -81,20 +83,24 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", @@ -104,7 +110,7 @@ "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", "utimes": "^5.2.1", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5" } }, @@ -112,6 +118,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -120,6 +127,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true, "engines": { "node": ">=10" }, @@ -595,17 +603,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -729,33 +726,18 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "dependencies": { - "@emotion/memoize": "0.7.4" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -769,9 +751,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -785,9 +767,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "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==", "cpu": [ "arm64" ], @@ -801,9 +783,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -817,9 +799,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -833,9 +815,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -849,9 +831,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -865,9 +847,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "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==", "cpu": [ "x64" ], @@ -881,9 +863,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -897,9 +879,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -913,9 +895,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -929,9 +911,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "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==", "cpu": [ "loong64" ], @@ -945,9 +927,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -961,9 +943,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "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==", "cpu": [ "ppc64" ], @@ -977,9 +959,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -993,9 +975,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -1009,9 +991,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1025,9 +1007,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1041,9 +1023,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1057,9 +1039,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "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==", "cpu": [ "x64" ], @@ -1073,9 +1055,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1089,9 +1071,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "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==", "cpu": [ "ia32" ], @@ -1105,9 +1087,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -1124,6 +1106,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { "eslint-visitor-keys": "^3.3.0" }, @@ -1135,22 +1118,48 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -1158,7 +1167,7 @@ "strip-json-comments": "^3.1.1" }, "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" @@ -1168,6 +1177,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1179,17 +1189,53 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fastify/busboy": { @@ -1201,40 +1247,6 @@ "node": ">=14" } }, - "node_modules/@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "dependencies": { - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "node_modules/@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -1248,9 +1260,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.10.10", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.10.10.tgz", - "integrity": "sha512-HPa/K5NX6ahMoeBv15njAc/sfF4/jmiXLar9UlC2UfHFKZzsCVLc3wbe7+7qua7w9VPh2/L6EBxyAV7/E8Wftg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz", + "integrity": "sha512-bgxdZmgTrJZX50OjyVwz3+mNEnCTNkh3cIqGPWVNeW9jX6bn1ZkU80uPd+67/ZpIJIjRQ9qaHCjhavyoWYxumg==", "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -1335,23 +1347,33 @@ "@hapi/hoek": "^9.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "engines": { "node": ">=12.22" }, @@ -1360,15 +1382,23 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==" + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "cpu": [ "arm64" ], @@ -1377,23 +1407,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "cpu": [ "x64" ], @@ -1402,23 +1428,19 @@ "darwin" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "cpu": [ "arm64" ], @@ -1426,20 +1448,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=11", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "cpu": [ "x64" ], @@ -1447,20 +1463,14 @@ "os": [ "darwin" ], - "engines": { - "macos": ">=10.13", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "cpu": [ "arm" ], @@ -1468,20 +1478,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "cpu": [ "arm64" ], @@ -1489,20 +1493,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "cpu": [ "s390x" ], @@ -1510,20 +1508,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.28", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ "x64" ], @@ -1531,20 +1523,14 @@ "os": [ "linux" ], - "engines": { - "glibc": ">=2.26", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "cpu": [ "arm64" ], @@ -1552,20 +1538,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ "x64" ], @@ -1573,20 +1553,14 @@ "os": [ "linux" ], - "engines": { - "musl": ">=1.2.2", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "cpu": [ "arm" ], @@ -1595,23 +1569,19 @@ "linux" ], "engines": { - "glibc": ">=2.28", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "cpu": [ "arm64" ], @@ -1620,23 +1590,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "cpu": [ "s390x" ], @@ -1645,23 +1611,19 @@ "linux" ], "engines": { - "glibc": ">=2.31", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ "x64" ], @@ -1670,23 +1632,19 @@ "linux" ], "engines": { - "glibc": ">=2.26", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "cpu": [ "arm64" ], @@ -1695,23 +1653,19 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], @@ -1720,44 +1674,37 @@ "linux" ], "engines": { - "musl": ">=1.2.2", - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "cpu": [ "wasm32" ], "optional": true, "dependencies": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "cpu": [ "ia32" ], @@ -1766,19 +1713,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "cpu": [ "x64" ], @@ -1787,10 +1731,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0", - "npm": ">=9.6.5", - "pnpm": ">=7.1.0", - "yarn": ">=3.2.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1932,6 +1873,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -2050,11 +1992,11 @@ ] }, "node_modules/@nestjs/bull-shared": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.1.1.tgz", - "integrity": "sha512-su7eThDrSz1oQagEi8l+1CyZ7N6nMgmyAX0DuZoXqT1KEVEDqGX7x80RlPVF60m/8SYOskckGMjJROSfNQcErw==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "dependencies": { - "tslib": "2.6.2" + "tslib": "2.6.3" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", @@ -2062,12 +2004,12 @@ } }, "node_modules/@nestjs/bullmq": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.1.1.tgz", - "integrity": "sha512-afYx1wYCKtXEu1p0S1+qw2o7QaZWr/EQgF7Wkt3YL8RBIECy5S4C450gv/cRGd8EZjlt6bw8hGCLqR2Q5VjHpQ==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "dependencies": { - "@nestjs/bull-shared": "^10.1.1", - "tslib": "2.6.2" + "@nestjs/bull-shared": "^10.2.1", + "tslib": "2.6.3" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", @@ -2076,9 +2018,9 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", + "integrity": "sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -2098,7 +2040,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.94.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2134,12 +2076,12 @@ } }, "node_modules/@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.6.tgz", + "integrity": "sha512-KkezkZvU9poWaNq4L+lNvx+386hpOxPJkfXBBeSMrcqBOx8kVr36TGN2uYkF4Ta4zNu1KbCjmZbc0rhHSg296g==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2162,35 +2104,21 @@ } }, "node_modules/@nestjs/common/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, - "node_modules/@nestjs/config": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", - "integrity": "sha512-p6yv/CvoBewJ72mBq4NXgOAi2rSQNWx3a+IMJLVKS2uiwFCOQQuiIatGwq6MRjXV3Jr+B41iUO8FIf4xBrZ4/w==", - "dependencies": { - "dotenv": "16.4.5", - "dotenv-expand": "10.0.0", - "lodash": "4.17.21" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "rxjs": "^7.1.0" - } + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.6.tgz", + "integrity": "sha512-zXVPxCNRfO6gAy0yvEDjUxE/8gfZICJFpsl2lZAUH31bPb6m+tXuhUq2mVCTEltyMYQ+DYtRe+fEYM2v152N1g==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2218,14 +2146,14 @@ } }, "node_modules/@nestjs/core/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/event-emitter": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", - "integrity": "sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz", + "integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==", "dependencies": { "eventemitter2": "6.4.9" }, @@ -2254,15 +2182,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.6.tgz", + "integrity": "sha512-HcyCpAKccAasrLSGRTGWv5BKRs0rwTIFOSsk6laNyqfqvgvYcJQAedarnm4jmaemtmSJ0PFI9PmtEZADd2ahCg==", "dependencies": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.1", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2274,17 +2202,17 @@ } }, "node_modules/@nestjs/platform-express/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.6.tgz", + "integrity": "sha512-lGv99O7C00wtnGq9M0mcwrOpH2qmuqAXQyvo/d/I7rmaf3OO1Sg8qWDLAnPKHdaumwOL2mnET3kvCJ06MaL6WA==", "dependencies": { - "socket.io": "4.7.5", - "tslib": "2.6.3" + "socket.io": "4.8.0", + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2296,15 +2224,60 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-socket.io/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==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/@nestjs/platform-socket.io/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==", + "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/@nestjs/platform-socket.io/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "dependencies": { "cron": "3.1.7", "uuid": "10.0.0" @@ -2327,14 +2300,14 @@ } }, "node_modules/@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.2.tgz", + "integrity": "sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.8", - "@angular-devkit/schematics": "17.3.8", - "comment-json": "4.2.3", + "@angular-devkit/core": "17.3.10", + "@angular-devkit/schematics": "17.3.10", + "comment-json": "4.2.5", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, @@ -2342,22 +2315,91 @@ "typescript": ">=4.8.2" } }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "17.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.10.tgz", + "integrity": "sha512-czdl54yxU5DOAGy/uUPNjJruoBDTgwi/V+eOgLNybYhgrc+TsY0f7uJ11yEk/pz5sCov7xIiS7RdRv96waS7vg==", + "dev": true, + "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/@nestjs/schematics/node_modules/@angular-devkit/core/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==", + "dev": true + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "17.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.10.tgz", + "integrity": "sha512-FHcNa1ktYRd0SKExCsNJpR75RffsyuPIV8kvBXzXnLHmXMqvl25G2te3yYJ9yYqy9OLy/58HZznZTxWRyUdHOg==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.10", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.8", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics/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==", + "dev": true + }, "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==", "dev": true }, + "node_modules/@nestjs/schematics/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, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { @@ -2381,12 +2423,12 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.6.tgz", + "integrity": "sha512-aiDicKhlGibVGNYuew399H5qZZXaseOBT/BS+ERJxxCmco7ZdAqaujsNjSaSbTK9ojDPf27crLT0C4opjqJe3A==", "dev": true, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2408,9 +2450,9 @@ } }, "node_modules/@nestjs/testing/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true }, "node_modules/@nestjs/typeorm": { @@ -2429,13 +2471,13 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.6.tgz", + "integrity": "sha512-53YqDQylPAOudNFiiBvrN8QrRl/sZ9oEjKbD3wBVgrFREbaiuTySoyyy6HwVs60HW29uQwck+Bp7qkKGjhtQKg==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", @@ -2451,19 +2493,19 @@ } }, "node_modules/@nestjs/websockets/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "cpu": [ "arm64" ], @@ -2476,9 +2518,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "cpu": [ "x64" ], @@ -2491,9 +2533,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "cpu": [ "arm64" ], @@ -2506,9 +2548,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "cpu": [ "arm64" ], @@ -2521,9 +2563,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "cpu": [ "x64" ], @@ -2536,9 +2578,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "cpu": [ "x64" ], @@ -2551,9 +2593,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "cpu": [ "arm64" ], @@ -2566,9 +2608,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "cpu": [ "ia32" ], @@ -2581,9 +2623,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "cpu": [ "x64" ], @@ -2658,67 +2700,68 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.0.tgz", - "integrity": "sha512-HxjD7xH9iAE4OyhNaaSec65i1H6QZYBWSwWkowFfsc5YAcDvJG30/J1sRKXEQqdmUcKTXEAnA66UciqZha/4+Q==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.54.0.tgz", + "integrity": "sha512-9HhEh5GqFrassUndqJsyW7a0PzfyWr2eV2xwzHLIS+wX3125+9HE9FMRAKmJRwxZhgZGwH3HNQQjoMGZqmOeVA==", "dependencies": { - "@opentelemetry/api": "^1.0.0" + "@opentelemetry/api": "^1.3.0" }, "engines": { "node": ">=14" } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.48.0.tgz", - "integrity": "sha512-meON9LM9dyPun8ZlIs90BzqHAIWfWkC8g+OoPuIEeV5UOSyKqMsWtbMyiTbs/k/i7k1V4miJQMX/PcLbD7pWcQ==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.52.0.tgz", + "integrity": "sha512-J9SgX7NOpTvQ7itvlOlHP3lTlsMWtVh5WQSHUSTlg2m3A9HlZBri2DtZ8QgNj8rYWe0EQxQ3TQ3H6vabfun4vw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.39.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.0", - "@opentelemetry/instrumentation-bunyan": "^0.40.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", - "@opentelemetry/instrumentation-connect": "^0.38.0", - "@opentelemetry/instrumentation-cucumber": "^0.8.0", - "@opentelemetry/instrumentation-dataloader": "^0.11.0", - "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.0", - "@opentelemetry/instrumentation-fastify": "^0.38.0", - "@opentelemetry/instrumentation-fs": "^0.14.0", - "@opentelemetry/instrumentation-generic-pool": "^0.38.0", - "@opentelemetry/instrumentation-graphql": "^0.42.0", - "@opentelemetry/instrumentation-grpc": "^0.52.0", - "@opentelemetry/instrumentation-hapi": "^0.40.0", - "@opentelemetry/instrumentation-http": "^0.52.0", - "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-knex": "^0.38.0", - "@opentelemetry/instrumentation-koa": "^0.42.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", - "@opentelemetry/instrumentation-memcached": "^0.38.0", - "@opentelemetry/instrumentation-mongodb": "^0.46.0", - "@opentelemetry/instrumentation-mongoose": "^0.40.0", - "@opentelemetry/instrumentation-mysql": "^0.40.0", - "@opentelemetry/instrumentation-mysql2": "^0.40.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.39.0", - "@opentelemetry/instrumentation-net": "^0.38.0", - "@opentelemetry/instrumentation-pg": "^0.43.0", - "@opentelemetry/instrumentation-pino": "^0.41.0", - "@opentelemetry/instrumentation-redis": "^0.41.0", - "@opentelemetry/instrumentation-redis-4": "^0.41.0", - "@opentelemetry/instrumentation-restify": "^0.40.0", - "@opentelemetry/instrumentation-router": "^0.39.0", - "@opentelemetry/instrumentation-socket.io": "^0.41.0", - "@opentelemetry/instrumentation-tedious": "^0.12.0", - "@opentelemetry/instrumentation-undici": "^0.4.0", - "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.28.10", - "@opentelemetry/resource-detector-aws": "^1.5.2", - "@opentelemetry/resource-detector-azure": "^0.2.9", - "@opentelemetry/resource-detector-container": "^0.3.11", - "@opentelemetry/resource-detector-gcp": "^0.29.10", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/instrumentation-amqplib": "^0.43.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.46.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.45.0", + "@opentelemetry/instrumentation-bunyan": "^0.42.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.42.0", + "@opentelemetry/instrumentation-connect": "^0.40.0", + "@opentelemetry/instrumentation-cucumber": "^0.10.0", + "@opentelemetry/instrumentation-dataloader": "^0.13.0", + "@opentelemetry/instrumentation-dns": "^0.40.0", + "@opentelemetry/instrumentation-express": "^0.44.0", + "@opentelemetry/instrumentation-fastify": "^0.41.0", + "@opentelemetry/instrumentation-fs": "^0.16.0", + "@opentelemetry/instrumentation-generic-pool": "^0.40.0", + "@opentelemetry/instrumentation-graphql": "^0.44.0", + "@opentelemetry/instrumentation-grpc": "^0.54.0", + "@opentelemetry/instrumentation-hapi": "^0.42.0", + "@opentelemetry/instrumentation-http": "^0.54.0", + "@opentelemetry/instrumentation-ioredis": "^0.44.0", + "@opentelemetry/instrumentation-kafkajs": "^0.4.0", + "@opentelemetry/instrumentation-knex": "^0.41.0", + "@opentelemetry/instrumentation-koa": "^0.44.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.41.0", + "@opentelemetry/instrumentation-memcached": "^0.40.0", + "@opentelemetry/instrumentation-mongodb": "^0.48.0", + "@opentelemetry/instrumentation-mongoose": "^0.43.0", + "@opentelemetry/instrumentation-mysql": "^0.42.0", + "@opentelemetry/instrumentation-mysql2": "^0.42.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.41.0", + "@opentelemetry/instrumentation-net": "^0.40.0", + "@opentelemetry/instrumentation-pg": "^0.47.0", + "@opentelemetry/instrumentation-pino": "^0.43.0", + "@opentelemetry/instrumentation-redis": "^0.43.0", + "@opentelemetry/instrumentation-redis-4": "^0.43.0", + "@opentelemetry/instrumentation-restify": "^0.42.0", + "@opentelemetry/instrumentation-router": "^0.41.0", + "@opentelemetry/instrumentation-socket.io": "^0.43.0", + "@opentelemetry/instrumentation-tedious": "^0.15.0", + "@opentelemetry/instrumentation-undici": "^0.7.0", + "@opentelemetry/instrumentation-winston": "^0.41.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.4", + "@opentelemetry/resource-detector-aws": "^1.7.0", + "@opentelemetry/resource-detector-azure": "^0.2.12", + "@opentelemetry/resource-detector-container": "^0.5.0", + "@opentelemetry/resource-detector-gcp": "^0.29.13", "@opentelemetry/resources": "^1.24.0", - "@opentelemetry/sdk-node": "^0.52.0" + "@opentelemetry/sdk-node": "^0.54.0" }, "engines": { "node": ">=14" @@ -2728,9 +2771,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.25.1.tgz", - "integrity": "sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.27.0.tgz", + "integrity": "sha512-CdZ3qmHCwNhFAzjTgHqrDQ44Qxcpz43cVxZRhOs+Ns/79ug+Mr84Bkb626bkJLkA3+BLimA5YAEVRlJC6pFb7g==", "engines": { "node": ">=14" }, @@ -2739,11 +2782,11 @@ } }, "node_modules/@opentelemetry/core": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.0.tgz", - "integrity": "sha512-n0B3s8rrqGrasTgNkXLKXzN0fXo+6IYP7M5b7AMsrZM33f/y6DS6kJ0Btd7SespASWq8bgL3taLo0oe0vB52IQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -2752,14 +2795,16 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-prometheus": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.52.1.tgz", - "integrity": "sha512-hwK0QnjtqAxGpQAXMNUY+kTT5CnHyz1I0lBA8SFySvaFtExZm7yQg/Ua/i+RBqgun7WkUbkUVJzEi3lKpJ7WdA==", + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.54.0.tgz", + "integrity": "sha512-CQC9xl9p8EIvx2KggdM7yffbpmUArKjiqAcjTTTEvqE8kOOf71NSuBU0FXs14FU8vBGTUlsr3oI4vGeWF8FakA==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-metrics": "1.25.1" + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/sdk-logs": "0.54.0" }, "engines": { "node": ">=14" @@ -2768,158 +2813,124 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.54.0.tgz", + "integrity": "sha512-EX/5YPtFw5hugURWSmOtJEGsjphkwTRAiv2yay40ADCLEzajhI/tM3v/7hFCj+rm37sGFMNawpi3mGLvfKGexQ==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/sdk-logs": "0.54.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.54.0.tgz", + "integrity": "sha512-Q8p1eLP6BGu26VdiR8qBiyufXTZimUl2kv6EwZZPLRU0CJWAFR562UOyUtDxbwQioQFq57DVjCd6mQWBvydAlg==", + "dependencies": { + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-trace-base": "1.27.0" + }, "engines": { "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.54.0.tgz", + "integrity": "sha512-httb+/c36hZvkIR9SqwXj+fLeE2XDdWfZqGO24MboNMHihmnvjE0/LN29I9CjsJqC2jEi8FErfQha/JeOfsFaA==", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.52.1.tgz", - "integrity": "sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.54.0.tgz", + "integrity": "sha512-DOoK7yk/L/RHoyuYTxIyGY7PLFSTS7OGNks9htMS7eAFkm4Lsa6EtPlGANCT39NXWP4XIQR1c+Y+YIQ7lJdI+w==", "dependencies": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.52.1.tgz", - "integrity": "sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.54.0.tgz", + "integrity": "sha512-00X6rtr6Ew59+MM9pPSH7Ww5ScpWKBLiBA49awbPqQuVL/Bp0qp7O1cTxKHgjWdNkhsELzJxAEYwuRnDGrMXyA==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.52.1.tgz", - "integrity": "sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.54.0.tgz", + "integrity": "sha512-cpDQj5wl7G8pLu3lW94SnMpn0C85A9Ehe7+JBow2IL5DGPWXTkynFngMtCC3PpQzQgzlyOVe0MVZfoBB3M5ECA==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/exporter-zipkin": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.25.1.tgz", - "integrity": "sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.27.0.tgz", + "integrity": "sha512-eGMY3s4QprspFZojqsuQyQpWNFpo+oNVE/aosTbtvAlrJBAlvXcwwsOROOHOd8Y9lkU4i0FpQW482rcXkgwCSw==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -2928,28 +2939,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/host-metrics": { "version": "0.35.1", "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.1.tgz", @@ -2966,13 +2955,13 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.0.tgz", - "integrity": "sha512-LPwSIrw+60cheWaXsfGL8stBap/AppKQJFE+qqRvzYrgttXFH2ofoIMxWadeqPTq4BYOXM/C7Bdh/T+B60xnlQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.54.0.tgz", + "integrity": "sha512-B0Ydo9g9ehgNHwtpc97XivEzjz0XBKR6iQ83NTENIxEEf5NHE0otZQuZLgDdey1XNk+bP1cfRpIkSFWM5YlSyg==", "dependencies": { - "@opentelemetry/api-logs": "0.52.0", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.8.0", + "@opentelemetry/api-logs": "0.54.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" @@ -2985,13 +2974,13 @@ } }, "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.39.0.tgz", - "integrity": "sha512-i9SccU5bbHivmmN8ba8HitLnM915BWdGwk5Jl6dwHTp0eV4KpoprZLE/jXUY1AAP/LXpTrM7NgVHmslFSVWRYA==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.43.0.tgz", + "integrity": "sha512-ALjfQC+0dnIEcvNYsbZl/VLh7D2P1HhFF4vicRKHhHFIUV3Shpg4kXgiek5PLhmeKSIPiUB25IYH5RIneclL4A==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3001,15 +2990,14 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-lambda": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.43.0.tgz", - "integrity": "sha512-pSxcWlsE/pCWQRIw92sV2C+LmKXelYkjkA7C5s39iPUi4pZ2lA1nIiw+1R/y2pDEhUHcaKkNyljQr3cx9ZpVlQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.46.0.tgz", + "integrity": "sha512-rNmhTC1e1qQD4jw+TZSHlpLYNhrkbKA0P5rlqPpTVHqZXHQctu9+dity2lLBh4DlFKt4p/ibVDLVDoBqjvetKA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/propagator-aws-xray": "^1.3.1", - "@opentelemetry/resources": "^1.8.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/aws-lambda": "8.10.122" + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "8.10.143" }, "engines": { "node": ">=14" @@ -3019,14 +3007,14 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.0.tgz", - "integrity": "sha512-klfA48MT0uZY/mGw3cYdQeCXTyMhtY4FzHcZy9R7DdTcuCExgbxWrUlOSiqIJ5kBgsCZfBMEeA6UQKDBwa6X7Q==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.45.0.tgz", + "integrity": "sha512-3EGgC0LFZuFfXcOeslhXHhsiInVhhN046YQsYIPflsicAk7v0wN946sZKWuerEfmqx/kFXOsbOeI1SkkTRmqWQ==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/propagation-utils": "^0.30.10", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/propagation-utils": "^0.30.12", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3036,12 +3024,12 @@ } }, "node_modules/@opentelemetry/instrumentation-bunyan": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.40.0.tgz", - "integrity": "sha512-aZ4cXaGWwj79ZXSYrgFVsrDlE4mmf2wfvP9bViwRc0j75A6eN6GaHYHqufFGMTCqASQn5pIjjP+Bx+PWTGiofw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.42.0.tgz", + "integrity": "sha512-GBh6ybwKmFZjc86SyHVx72jHg+4pFPaXT3IZgJ4QtnMsMf0/q5m2aHAjid+yakmEkApsnRWX8pJ8nkl1e+6mag==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0", "@types/bunyan": "1.8.9" }, "engines": { @@ -3052,12 +3040,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.40.0.tgz", - "integrity": "sha512-JxbM39JU7HxE9MTKKwi6y5Z3mokjZB2BjwfqYi4B3Y29YO3I42Z7eopG6qq06yWZc+nQli386UDQe0d9xKmw0A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.42.0.tgz", + "integrity": "sha512-35I9Gw4BeSs9NPe7fugu9e/mWKaapc/N1wounHnGt259/Q3ISGMOQRrOwIBw+x/XJygJvn4Ss1c+r5h89TsVAw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3067,13 +3055,13 @@ } }, "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.38.0.tgz", - "integrity": "sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.40.0.tgz", + "integrity": "sha512-3aR/3YBQ160siitwwRLjwqrv2KBT16897+bo6yz8wIfel6nWOxTZBJudcbsK3p42pTC7qrbotJ9t/1wRLpv79Q==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.36" }, "engines": { @@ -3084,12 +3072,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cucumber": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.8.0.tgz", - "integrity": "sha512-ieTm4RBIlZt2brPwtX5aEZYtYnkyqhAVXJI9RIohiBVMe5DxiwCwt+2Exep/nDVqGPX8zRBZUl4AEw423OxJig==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.10.0.tgz", + "integrity": "sha512-5sT6Ap3W7StEL0Oax/vd1YTEcTPTefx+9myzkKrr72hxzFzSooGRCxlU3sfPwZqWptUV7+QWTMd7SqGEEPnE/w==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3099,11 +3087,11 @@ } }, "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.11.0.tgz", - "integrity": "sha512-27urJmwkH4KDaMJtEv1uy2S7Apk4XbN4AgWMdfMJbi7DnOduJmeuA+DpJCwXB72tEWXo89z5T3hUVJIDiSNmNw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.13.0.tgz", + "integrity": "sha512-wbU3WdgUAXljEIY2nfpkqID/VH70ThnES8mZZHKCZlV/Pl5T4+qmrVdT7U9/WUzz8flwsXfER6T6jl48Wbl+LQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3113,12 +3101,11 @@ } }, "node_modules/@opentelemetry/instrumentation-dns": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.38.0.tgz", - "integrity": "sha512-Um07I0TQXDWa+ZbEAKDFUxFH40dLtejtExDOMLNJ1CL8VmOmA71qx93Qi/QG4tGkiI1XWqr7gF/oiMCJ4m8buQ==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.40.0.tgz", + "integrity": "sha512-tLNR8XLPiYRKKk3/UqifXnPP2TVt1RcwvHU0R1ETL1xkZ1ZHMTmSC4x6TignnHOFtRixtJ05EgMGejnffaBXkQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "semver": "^7.5.4" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3128,13 +3115,13 @@ } }, "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.0.tgz", - "integrity": "sha512-/B7fbMdaf3SYe5f1P973tkqd6s7XZirjpfkoJ63E7nltU30qmlgm9tY5XwZOzAFI0rHS9tbrFI2HFPAvQUFe/A==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.44.0.tgz", + "integrity": "sha512-GWgibp6Q0wxyFaaU8ERIgMMYgzcHmGrw3ILUtGchLtLncHNOKk0SNoWGqiylXWWT4HTn5XdV8MGawUgpZh80cA==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3144,13 +3131,13 @@ } }, "node_modules/@opentelemetry/instrumentation-fastify": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.38.0.tgz", - "integrity": "sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.41.0.tgz", + "integrity": "sha512-pNRjFvf0mvqfJueaeL/qEkuGJwgtE5pgjIHGYwjc2rMViNCrtY9/Sf+Nu8ww6dDd/Oyk2fwZZP7i0XZfCnETrA==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3160,12 +3147,12 @@ } }, "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.14.0.tgz", - "integrity": "sha512-pVc8P5AgliC1DphyyBUgsxXlm2XaPH4BpYvt7rAZDMIqUpRk8gs19SioABtKqqxvFzg5jPtgJfJsdxq0Y+maLw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.16.0.tgz", + "integrity": "sha512-hMDRUxV38ln1R3lNz6osj3YjlO32ykbHqVrzG7gEhGXFQfu7LJUx8t9tEwE4r2h3CD4D0Rw4YGDU4yF4mP3ilg==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3175,11 +3162,11 @@ } }, "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.38.0.tgz", - "integrity": "sha512-0/ULi6pIco1fEnDPmmAul8ZoudFL7St0hjgBbWZlZPBCSyslDll1J7DFeEbjiRSSyUd+0tu73ae0DOKVKNd7VA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.40.0.tgz", + "integrity": "sha512-k+/JlNDHN3bPi/Cir+Ew6tKHFVCa1ZFeQyGUw5HQkRX/twCRaN3kJFXJW+rDAN90XwK3RtC9AWwBihDGh/oSlQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3189,11 +3176,11 @@ } }, "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.42.0.tgz", - "integrity": "sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.44.0.tgz", + "integrity": "sha512-FYXTe3Bv96aNpYktqm86BFUTpjglKD0kWI5T5bxYkLUPEPvFn38vWGMJTGrDMVou/i55E4jlWvcm6hFIqLsMbg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3203,12 +3190,12 @@ } }, "node_modules/@opentelemetry/instrumentation-grpc": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.52.0.tgz", - "integrity": "sha512-YYhA2pbhMWgF5Hp6eR7AHp1utzZQ3Y0VB8GIwd8zJoLtAuQRZa1N29DUtZ+t/pGRJF+xGPVI+vP+7ugHgeN0zQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.54.0.tgz", + "integrity": "sha512-IwLwAf1uC6I5lYjUxfvG0jFuppqNuaBIiaDxYFHMWeRX1Rejh4eqtQi2u+VVtSOHsCn2sRnS9hOxQ2w7+zzPLw==", "dependencies": { - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -3218,13 +3205,13 @@ } }, "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.40.0.tgz", - "integrity": "sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.42.0.tgz", + "integrity": "sha512-TQC0BtIWLHrp6nKsYdZ5t5B7aiZ16BwbRqZtYYQxeJVsq/HQTANWpknjtA7KMxv5tAUMCrU/eDo8F3qioUOSZg==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3234,13 +3221,14 @@ } }, "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.52.0.tgz", - "integrity": "sha512-E6ywZuxTa4LnVXZGwL1oj3e2Eog1yIaNqa8KjKXoGkDNKte9/SjQnePXOmhQYI0A9nf0UyFbP9aKd+yHrkJXUA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.54.0.tgz", + "integrity": "sha512-ovl0UrL+vGpi0O7fdZ1mHRdiQkuv6NGMRBRKZZygVCUFNXdoqTpvJRRbTYih5U5FC+PHIFssEordmlblRCaGUg==", "dependencies": { - "@opentelemetry/core": "1.25.0", - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0", + "forwarded-parse": "2.1.2", "semver": "^7.5.2" }, "engines": { @@ -3251,13 +3239,28 @@ } }, "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.42.0.tgz", - "integrity": "sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.44.0.tgz", + "integrity": "sha512-312pE2xc0ihX9haTf9WC4OF9in5EfVO1y5I8Ef9aMQKJNhuSe3IgzQAqGoLfaYajC+ig0IZ9SQKU8mRbFwHU+A==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.4.0.tgz", + "integrity": "sha512-I9VwDG314g7SDL4t8kD/7+1ytaDBRbZQjhVaQaVIDR8K+mlsoBhLsWH79yHxhHQKvwCSZwqXF+TiTOhoQVUt7A==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3267,12 +3270,12 @@ } }, "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.38.0.tgz", - "integrity": "sha512-EFef6Ss5ATsf5AxJOLE+pxkfupcWDaejkPH+2q7TNeG1UwsBFobfiWM+iHROZ1Cl/y3mTi60MW70FxsaX2/TjA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.41.0.tgz", + "integrity": "sha512-OhI1SlLv5qnsnm2dOVrian/x3431P75GngSpnR7c4fcVFv7prXGYu29Z6ILRWJf/NJt6fkbySmwdfUUnFnHCTg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3282,13 +3285,13 @@ } }, "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.42.0.tgz", - "integrity": "sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.44.0.tgz", + "integrity": "sha512-ryPqGIQ4hpMGd85bAGjRMDAy/ic+Qdh1GtFGJo9KaXdzbcvZoF1ZgXVsKTYDxbD1n5C0BoQy6rcWg8Lu68iCJA==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3298,11 +3301,11 @@ } }, "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.39.0.tgz", - "integrity": "sha512-eU1Wx1RRTR/2wYXFzH9gcpB8EPmhYlNDIUHzUXjyUE0CAXEJhBLkYNlzdaVCoQDw2neDqS+Woshiia6+emWK9A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.41.0.tgz", + "integrity": "sha512-6OePkk4RYCPVsnS0TroEK6UZzxxxjVWaE6EPdOn2qxGHMtm+Qb80tiBQ6BbmC+f7bjc27O85JY8gxeTybhHZXw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3312,12 +3315,12 @@ } }, "node_modules/@opentelemetry/instrumentation-memcached": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.38.0.tgz", - "integrity": "sha512-tPmyqQEZNyrvg6G+iItdlguQEcGzfE+bJkpQifmBXmWBnoS5oU3UxqtyYuXGL2zI9qQM5yMBHH4nRXWALzy7WA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.40.0.tgz", + "integrity": "sha512-VzJUUH6cVz8yrb25RvvjhxCpwu4vUk28I0m5nnnhebULOo8p9lda5PgQeVde2+jQAd977C/vN714fkbYOmwb+A==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/memcached": "^2.2.6" }, "engines": { @@ -3328,13 +3331,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.46.0.tgz", - "integrity": "sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.48.0.tgz", + "integrity": "sha512-9YWvaGvrrcrydMsYGLu0w+RgmosLMKe3kv/UNlsPy8RLnCkN2z+bhhbjjjuxtUmvEuKZMCoXFluABVuBr1yhjw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/sdk-metrics": "^1.9.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3344,13 +3346,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.40.0.tgz", - "integrity": "sha512-niRi5ZUnkgzRhIGMOozTyoZIvJKNJyhijQI4nF4iFSb+FUx2v5fngfR+8XLmdQAO7xmsD8E5vEGdDVYVtKbZew==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.43.0.tgz", + "integrity": "sha512-y1mWuL/zb6IKi199HkROgmStxF/ybEsnKYgx+/lpLATd57oZHOqrXP9tLmp9qRVI5c6P5XEWfe7ZCvrj07iDMQ==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3360,13 +3362,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.40.0.tgz", - "integrity": "sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.42.0.tgz", + "integrity": "sha512-1GN2EBGVSZABGQ25MSz3faeBW/DwhzmE10aNW1/A2mvQAxF1CvpMk17YmNUzwapVt29iKsiU3SXQG7vjh/019A==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/mysql": "2.15.22" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" }, "engines": { "node": ">=14" @@ -3376,12 +3378,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.40.0.tgz", - "integrity": "sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.42.0.tgz", + "integrity": "sha512-CQqOjCbHwEnaC+Bd6Sms+82iJkSbPpd7jD7Jwif7q8qXo6yrKLVDYDVK+zKbfnmQtu2xHaHj+xiq4tyjb3sMfg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1" }, "engines": { @@ -3392,12 +3394,12 @@ } }, "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.39.0.tgz", - "integrity": "sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.41.0.tgz", + "integrity": "sha512-XCqtghFktpcJ2BOaJtFfqtTMsHffJADxfYhJl28WT6ygCChS2uZVxMKKLsy+i9VtPaw/i1IumPICL6mbhwq+Vw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3407,12 +3409,12 @@ } }, "node_modules/@opentelemetry/instrumentation-net": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.38.0.tgz", - "integrity": "sha512-stjow1PijcmUquSmRD/fSihm/H61DbjPlJuJhWUe7P22LFPjFhsrSeiB5vGj3vn+QGceNAs+kioUTzMGPbNxtg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.40.0.tgz", + "integrity": "sha512-abErnVRxTmtiF7EvBISW81Se2nj/j3Xtpfy//9++dgvDOXwbcD1Xz1via6ZHOm/VamboGhqPlYiO7ABzluPLwg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3422,15 +3424,16 @@ } }, "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.43.0.tgz", - "integrity": "sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.47.0.tgz", + "integrity": "sha512-aKu5PCeUv3S8s1wq60JZ2o3DWV2wqvO7WAktjmkx5wXd2+tZRfyDCKFHbP90QuDG1HDzjJ138Ob4d4rJdPETCQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.4" + "@types/pg-pool": "2.0.6" }, "engines": { "node": ">=14" @@ -3450,13 +3453,13 @@ } }, "node_modules/@opentelemetry/instrumentation-pino": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.41.0.tgz", - "integrity": "sha512-Kpv0fJRk/8iMzMk5Ue5BsUJfHkBJ2wQoIi/qduU1a1Wjx9GLj6J2G17PHjPK5mnZjPNzkFOXFADZMfgDioliQw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.43.0.tgz", + "integrity": "sha512-jlOOgbODWRRNknWXY1VLgmqgG0SO4kLgU3XnejjO/3De4OisroAsMGk+1cRB5AQ6WZ8WLAMkMyTShaOe6j2Asw==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", + "@opentelemetry/api-logs": "^0.54.0", "@opentelemetry/core": "^1.25.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3466,13 +3469,13 @@ } }, "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.41.0.tgz", - "integrity": "sha512-RJ1pwI3btykp67ts+5qZbaFSAAzacucwBet5/5EsKYtWBpHbWwV/qbGN/kIBzXg5WEZBhXLrR/RUq0EpEUpL3A==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.43.0.tgz", + "integrity": "sha512-dufe08W3sCOjutbTJmV6tg2Y3+7IBe59oQrnIW2RCgjRhsW0Jjaenezt490eawO0MdXjUfFyrIUg8WetKhE4xA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3482,13 +3485,13 @@ } }, "node_modules/@opentelemetry/instrumentation-redis-4": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.41.0.tgz", - "integrity": "sha512-H7IfGTqW2reLXqput4yzAe8YpDC0fmVNal95GHMLOrS89W+qWUKIqxolSh63hJyfmwPSFwXASzj7wpSk8Az+Dg==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.43.0.tgz", + "integrity": "sha512-6B2+CFRY9xRnkeZrSvlTyY2yB/zAgxjbXS5EwXhE3ZAKR1hWWoUzaTADIKT5xe9/VbDW42U3UoOPCcaCmeAXww==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3498,13 +3501,13 @@ } }, "node_modules/@opentelemetry/instrumentation-restify": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.40.0.tgz", - "integrity": "sha512-sm/rH/GysY/KOEvZqYBZSLYFeXlBkHCgqPDgWc07tz+bHCN6mPs9P3otGOSTe7o3KAIM8Nc6ncCO59vL+jb2cA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.42.0.tgz", + "integrity": "sha512-ApDD9HNy6de6xrHmISEfkQHwwX1f1JrBj0ADnlk6tVdJ0j/vNmsZNLwaU2IA2K3mHqbp2YLarLgxAZp6rjcfWg==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3514,12 +3517,12 @@ } }, "node_modules/@opentelemetry/instrumentation-router": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.39.0.tgz", - "integrity": "sha512-LaXnVmD69WPC4hNeLzKexCCS19hRLrUw3xicneAMkzJSzNJvPyk7G6I7lz7VjQh1cooObPBt9gNyd3hhTCUrag==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.41.0.tgz", + "integrity": "sha512-IbvzgaoylMqStOOtwucEvSu5CDbfQN+H1ZZ2p6c9Kmvzptqh6G441GFy0FFVVqxOAHNhQm2w6n0Ag8trdBjCfw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3529,12 +3532,12 @@ } }, "node_modules/@opentelemetry/instrumentation-socket.io": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.41.0.tgz", - "integrity": "sha512-7fzDe9/FpO6NFizC/wnzXXX7bF9oRchsD//wFqy5g5hVEgXZCQ70IhxjrKdBvgjyIejR9T9zTvfQ6PfVKfkCAw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.43.0.tgz", + "integrity": "sha512-HAQoIZ6N/ey1L4jF69gmqo7RyeSv5rc4sZZAd1v6SVaB8ZolTEyWEzGlu1NRZZTnqfWNxDkX6J1/omWpDd9k0w==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3544,13 +3547,13 @@ } }, "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.12.0.tgz", - "integrity": "sha512-53xx7WQmpBPfxtVxOKRzzZxOjv9JzSdoy1aIvCtPM5/O407aYcdvj8wXxCQEiEfctFEovEHG4QgmdHz9BKidSQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.15.0.tgz", + "integrity": "sha512-Kb7yo8Zsq2TUwBbmwYgTAMPK0VbhoS8ikJ6Bup9KrDtCx2JC01nCb+M0VJWXt7tl0+5jARUbKWh5jRSoImxdCw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/tedious": "^4.0.10" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/tedious": "^4.0.14" }, "engines": { "node": ">=14" @@ -3560,12 +3563,12 @@ } }, "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.4.0.tgz", - "integrity": "sha512-UdMQBpz11SqtWlmDnk5SoqF5QDom4VmW8SVDt9Q2xuMWVh8lc0kVROfoo2pl7zU6H6gFR8eudb3eFXIdrFn0ew==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.7.0.tgz", + "integrity": "sha512-1AAqbVt1QOLgnc9DEkHS2R/0FIPI74ud5qgitwP9sVYzRg6e66bPSoAIARCyuANJrWCUrfgI69vLTfRxhBM+3A==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3575,12 +3578,12 @@ } }, "node_modules/@opentelemetry/instrumentation-winston": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.39.0.tgz", - "integrity": "sha512-v/1xziLJ9CyB3YDjBSBzbB70Qd0JwWTo36EqWK5m3AR0CzsyMQQmf3ZIZM6sgx7hXMcRQ0pnEYhg6nhrUQPm9A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.41.0.tgz", + "integrity": "sha512-qtqGDx2Plu71s9xaeXut0YgZFG/y68ENG9vvo/SODeEC+4/APiS/htQ5YNJIxxjOuxYowdFYRqV9Kmef2EUzmw==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0" }, "engines": { "node": ">=14" @@ -3590,138 +3593,61 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", - "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.54.0.tgz", + "integrity": "sha512-g+H7+QleVF/9lz4zhaR9Dt4VwApjqG5WWupy5CTMpWJfHB/nLxBbX73GBZDgdiNfh08nO3rNa6AS7fK8OhgF5g==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-transformer": "0.52.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-transformer": "0.54.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.52.1.tgz", - "integrity": "sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.54.0.tgz", + "integrity": "sha512-Yl2Dw0jlRWisEia9Hv/N8u2JLITCvzA6gAIKEvxpEu6nwHEftD2WhTJMIclkTtfmSW0rLmEEXymwmboG4xDN0Q==", "dependencies": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.52.1.tgz", - "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.54.0.tgz", + "integrity": "sha512-jRexIASQQzdK4AjfNIBfn94itAq4Q8EXR9d3b/OVbhd3kKQKvMr7GkxYDjbeTbY7hHCOLcLfJ3dpYQYGOe8qOQ==", "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", "protobufjs": "^7.3.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/propagation-utils": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.10.tgz", - "integrity": "sha512-hhTW8pFp9PSyosYzzuUL9rdm7HF97w3OCyElufFHyUnYnKkCBbu8ne2LyF/KSdI/xZ81ubxWZs78hX4S7pLq5g==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.12.tgz", + "integrity": "sha512-bgab3q/4dYUutUpQCEaSDa+mLoQJG3vJKeSiGuhM4iZaSpkz8ov0fs1MGil5PfxCo6Hhw3bB3bFYhUtnsfT/Pg==", "engines": { "node": ">=14" }, @@ -3744,11 +3670,11 @@ } }, "node_modules/@opentelemetry/propagator-b3": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.25.1.tgz", - "integrity": "sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.27.0.tgz", + "integrity": "sha512-pTsko3gnMioe3FeWcwTQR3omo5C35tYsKKwjgTCTVCgd3EOWL9BZrMfgLBmszrwXABDfUrlAEFN/0W0FfQGynQ==", "dependencies": { - "@opentelemetry/core": "1.25.1" + "@opentelemetry/core": "1.27.0" }, "engines": { "node": ">=14" @@ -3757,34 +3683,12 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/propagator-jaeger": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.25.1.tgz", - "integrity": "sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.27.0.tgz", + "integrity": "sha512-EI1bbK0wn0yIuKlc2Qv2LKBRw6LiUWevrjCF80fn/rlaB+7StAi8Y5s8DBqAYNpY7v1q86+NjU18v7hj2ejU3A==", "dependencies": { - "@opentelemetry/core": "1.25.1" + "@opentelemetry/core": "1.27.0" }, "engines": { "node": ">=14" @@ -3793,28 +3697,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/redis-common": { "version": "0.36.2", "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", @@ -3824,12 +3706,13 @@ } }, "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.28.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.28.10.tgz", - "integrity": "sha512-TZv/1Y2QCL6sJ+X9SsPPBXe4786bc/Qsw0hQXFsNTbJzDTGGUmOAlSZ2qPiuqAd4ZheUYfD+QA20IvAjUz9Hhg==", + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.4.tgz", + "integrity": "sha512-U3sWPoBXiEE51jJGhRrW19hLvrRbBbZWTp3Yc7IaRVFODNNzmibOolyi2ow1XN68UgRT4BRuwgwbnM5GbG/E5Q==", "dependencies": { - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3839,13 +3722,13 @@ } }, "node_modules/@opentelemetry/resource-detector-aws": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.5.2.tgz", - "integrity": "sha512-LNwKy5vJM5fvCDcbXVKwg6Y1pKT4WgZUsddGMnWMEhxJcQVZm2Z9vUkyHdQU7xvJtGwCO2/TkMWHPjU1KQNDJQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.7.0.tgz", + "integrity": "sha512-VxrwUi/9QcVIV+40d/jOKQthfD/E4/ppQ9FsYpDH7qy16cOO5519QOdihCQJYpVNbgDqf6q3hVrCy1f8UuG8YA==", "dependencies": { "@opentelemetry/core": "^1.0.0", - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3855,12 +3738,13 @@ } }, "node_modules/@opentelemetry/resource-detector-azure": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.2.9.tgz", - "integrity": "sha512-16Z6kyrmszoa7J1uj1kbSAgZuk11K07yEDj6fa3I9XBf8Debi8y4K8ex94kpxbCfEraWagXji3bCWvaq3k4dRg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.2.12.tgz", + "integrity": "sha512-iIarQu6MiCjEEp8dOzmBvCSlRITPFTinFB2oNKAjU6xhx8d7eUcjNOKhBGQTvuCriZrxrEvDaEEY9NfrPQ6uYQ==", "dependencies": { + "@opentelemetry/core": "^1.25.1", "@opentelemetry/resources": "^1.10.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3870,12 +3754,13 @@ } }, "node_modules/@opentelemetry/resource-detector-container": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.3.11.tgz", - "integrity": "sha512-22ndMDakxX+nuhAYwqsciexV8/w26JozRUV0FN9kJiqSWtA1b5dCVtlp3J6JivG5t8kDN9UF5efatNnVbqRT9Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.5.0.tgz", + "integrity": "sha512-ozp+ggcbl17xFfL91+DFgP8nmfzthNLxVTDOQUVgQgngVsSaBb5/I1Tnt63ZX2GCMdBJTxUBbFsqFvO0CjfGLg==", "dependencies": { - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3885,13 +3770,13 @@ } }, "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.29.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.10.tgz", - "integrity": "sha512-rm2HKJ9lsdoVvrbmkr9dkOzg3Uk0FksXNxvNBgrCprM1XhMoJwThI5i0h/5sJypISUAJlEeJS6gn6nROj/NpkQ==", + "version": "0.29.13", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.13.tgz", + "integrity": "sha512-vdotx+l3Q+89PeyXMgKEGnZ/CwzwMtuMi/ddgD9/5tKZ08DfDGB2Npz9m2oXPHRCjc4Ro6ifMqFlRyzIvgOjhg==", "dependencies": { "@opentelemetry/core": "^1.0.0", - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "gcp-metadata": "^6.0.0" }, "engines": { @@ -3902,12 +3787,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", - "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -3916,36 +3801,14 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.52.1.tgz", - "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.54.0.tgz", + "integrity": "sha512-HeWvOPiWhEw6lWvg+lCIi1WhJnIPbI4/OFZgHq9tKfpwF3LX6/kk3+GR8sGUGAEZfbjPElkkngzvd2s03zbD7Q==", "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1" + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" }, "engines": { "node": ">=14" @@ -3954,47 +3817,13 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", - "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.27.0.tgz", + "integrity": "sha512-JzWgzlutoXCydhHWIbLg+r76m+m3ncqvkCcsswXAQ4gqKS+LOHKhq+t6fx1zNytvLuaOUBur7EvWxECc4jPQKg==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "lodash.merge": "^4.6.2" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" }, "engines": { "node": ">=14" @@ -4003,46 +3832,27 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sdk-node": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.52.1.tgz", - "integrity": "sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.54.0.tgz", + "integrity": "sha512-F0mdwb4WPFJNypcmkxQnj3sIfh/73zkBgYePXMK8ghsBwYw4+PgM3/85WT6NzNUeOvWtiXacx5CFft2o7rGW3w==", "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/exporter-trace-otlp-grpc": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-proto": "0.52.1", - "@opentelemetry/exporter-zipkin": "1.25.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.54.0", + "@opentelemetry/exporter-logs-otlp-http": "0.54.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.54.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.54.0", + "@opentelemetry/exporter-trace-otlp-http": "0.54.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.54.0", + "@opentelemetry/exporter-zipkin": "1.27.0", + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/sdk-trace-node": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -4051,77 +3861,14 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/instrumentation": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", - "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/import-in-the-middle": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.1.tgz", - "integrity": "sha512-yhRwoHtiLGvmSozNOALgjRPFI6uYsds60EoMqqnXyyv+JOIW/BrrLejuTGBt+bq0T5tLzOHrN0T7xYTm4Qt/ng==", - "dependencies": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", - "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -4130,38 +3877,16 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.25.1.tgz", - "integrity": "sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.27.0.tgz", + "integrity": "sha512-dWZp/dVGdUEfRBjBq2BgNuBlFqHCxyyMc8FsN0NX15X07mxSUO0SZRLyK/fdAVrde8nqFI/FEdMH4rgU9fqJfQ==", "dependencies": { - "@opentelemetry/context-async-hooks": "1.25.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/propagator-b3": "1.25.1", - "@opentelemetry/propagator-jaeger": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", + "@opentelemetry/context-async-hooks": "1.27.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/propagator-b3": "1.27.0", + "@opentelemetry/propagator-jaeger": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", "semver": "^7.5.2" }, "engines": { @@ -4171,32 +3896,10 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.0.tgz", - "integrity": "sha512-M+kkXKRAIAiAP6qYyesfrC5TOmDpDVtsxuGfPcqd9B/iBrac+E14jYwrgm0yZBUIbIP2OnqC3j+UgkXLm1vxUQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", "engines": { "node": ">=14" } @@ -4216,9 +3919,9 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==" + "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==" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4300,628 +4003,29 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "dependencies": { - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "node_modules/@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.9.tgz", + "integrity": "sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==", "dependencies": { "prismjs": "1.29.0" }, @@ -4929,156 +4033,153 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.25.tgz", + "integrity": "sha512-lnfVVrThEcET5NPoeaXvrz9UxtWpGRcut2a07dLbyKgNbP7vj/cXTI5TuHtanCvhCddFpMDnElNRghDOfPzwUg==", "dependencies": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.9", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.1", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" }, "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "dependencies": { "md-to-react-email": "5.0.2" }, @@ -5086,24 +4187,24 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.1.tgz", + "integrity": "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==", "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -5113,52 +4214,52 @@ "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "engines": { "node": ">=18.0.0" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@rollup/pluginutils": { @@ -5202,9 +4303,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -5215,9 +4316,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -5228,9 +4329,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -5241,9 +4342,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -5254,9 +4355,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -5267,9 +4368,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -5280,9 +4381,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -5293,9 +4394,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -5306,9 +4407,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -5319,9 +4420,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -5332,9 +4433,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -5345,9 +4446,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -5358,9 +4459,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -5371,9 +4472,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -5384,9 +4485,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -5397,9 +4498,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -5466,14 +4567,14 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.2.tgz", - "integrity": "sha512-mjIlT0e6ygKR8LZ1TjtNrDVMhnB8qpyYAdwexhuVHY255yDdDQCpuPGi20odwnE82QhFBSIWs4HcENDVO/yiMw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", + "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", "devOptional": true, "hasInstallScript": true, "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.12" + "@swc/types": "^0.1.13" }, "engines": { "node": ">=10" @@ -5483,16 +4584,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.2", - "@swc/core-darwin-x64": "1.7.2", - "@swc/core-linux-arm-gnueabihf": "1.7.2", - "@swc/core-linux-arm64-gnu": "1.7.2", - "@swc/core-linux-arm64-musl": "1.7.2", - "@swc/core-linux-x64-gnu": "1.7.2", - "@swc/core-linux-x64-musl": "1.7.2", - "@swc/core-win32-arm64-msvc": "1.7.2", - "@swc/core-win32-ia32-msvc": "1.7.2", - "@swc/core-win32-x64-msvc": "1.7.2" + "@swc/core-darwin-arm64": "1.7.39", + "@swc/core-darwin-x64": "1.7.39", + "@swc/core-linux-arm-gnueabihf": "1.7.39", + "@swc/core-linux-arm64-gnu": "1.7.39", + "@swc/core-linux-arm64-musl": "1.7.39", + "@swc/core-linux-x64-gnu": "1.7.39", + "@swc/core-linux-x64-musl": "1.7.39", + "@swc/core-win32-arm64-msvc": "1.7.39", + "@swc/core-win32-ia32-msvc": "1.7.39", + "@swc/core-win32-x64-msvc": "1.7.39" }, "peerDependencies": { "@swc/helpers": "*" @@ -5504,9 +4605,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-Zb8KiGaESzOgh5HBnp6Vhs2fRpngHIT81JOfIo0oaGlzAckamnG7UAXC/yK6cQ8q2KXc78utJ/yq/NM2yVKLqw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", + "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", "cpu": [ "arm64" ], @@ -5520,9 +4621,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qb0HY9GEexpPm46Hb3OY7E6xb4r+eniiThm+0Gcnhf19EZV2ZlsCC8Rdbhmav33x++ZqSDzZ44fxMY2vnN5VDg==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", + "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", "cpu": [ "x64" ], @@ -5536,9 +4637,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-x2+MOK3RzH3yEkaukKtpDW/udM1x9GoYtXaLNqlq6ovAzZPQ9FDFI0pm1asL4akHUw3s7YTh1aUY7QscstJAHQ==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", + "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", "cpu": [ "arm" ], @@ -5552,9 +4653,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-4J3HGEDus7a9xnrJUFGyJJgvj4w+BFGiZvs08xbw4Z1ZN4uHJQiJiDsQEAWWciKUxrOndP3SocUq/GhEGiDm0g==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", + "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", "cpu": [ "arm64" ], @@ -5568,9 +4669,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-4FhQmYbj8SCmir4pHRLSn8IIFmRKHTL3eZFtOpm26RLME7rXL7Yt33DpzIeTRoHFIesI5NEfaR38WU5mY7P1pA==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", + "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", "cpu": [ "arm64" ], @@ -5584,9 +4685,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-Loz10Hy6z5mBIAOe6OInOVsYu+PVxyknCB3thtr7QH+uqEz6dcXhU2ERrO2Lf4dsTsFs/Wb80rv8zTSwB8dpsw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", + "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", "cpu": [ "x64" ], @@ -5600,9 +4701,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-8p8qNWaLcTa+qHX4NSv1KNm8BQ6zPoLXuOBo9DtOEqc+K60IISGKPCAS7TJlCcv0q20JnmxZ/cEWW5Qo4TR4XQ==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", + "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", "cpu": [ "x64" ], @@ -5616,9 +4717,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-eNWAYOalBlFrhv/IVSQ1dxu7qIGuhxlUJZTYa8jsgLnKt93vAFd2cjLtKZ85k1OibBnq9PkKQyo4NKVr4hBavw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", + "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", "cpu": [ "arm64" ], @@ -5632,9 +4733,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-BbpaCPCnbQHCzpQ9yDH3qp1Y5Ijd0NSMNk4qqESN2WWx0ojV2uBTjPou5NC2MZxk8fM3iJpJ05enf+IeaXuh6A==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", + "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", "cpu": [ "ia32" ], @@ -5648,9 +4749,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-21mf4Jg9Arx0lUnmRQtYd8IQB4WkY4LHJrvcz3EmKbwCTCXI5rQ6Ifnjk7EmG3Tizv0giHqQBQLu5NXWBz45Mg==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", + "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", "cpu": [ "x64" ], @@ -5669,28 +4770,30 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "dependencies": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "node_modules/@swc/types": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", - "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", + "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", + "devOptional": true, "dependencies": { "@swc/counter": "^0.1.3" } }, "node_modules/@testcontainers/postgresql": { - "version": "10.10.4", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.10.4.tgz", - "integrity": "sha512-yGRW3IYXAnv91ncOyhf6XVSMbKqfKQzFbFdaSu67agtXwIUYvGE+RFXa/SMZ6oNKHNWgMGKXB9Paj7+md79+VQ==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.2.tgz", + "integrity": "sha512-xd3u/rL8FrOBHFMu1aU+2d4sqPz9ffEb19ITtopT/tyBZWW9qCsgR6wSg0r2BJUd+2hT4UR5nR5cymi+ROkehw==", "dev": true, "dependencies": { - "testcontainers": "^10.10.4" + "testcontainers": "^10.13.2" } }, "node_modules/@tsconfig/node10": { @@ -5722,40 +4825,49 @@ "peer": true }, "node_modules/@turf/boolean-point-in-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", - "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "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==", "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@turf/invariant": "^7.1.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.6.2" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", + "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz", + "integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==", "dependencies": { - "@turf/helpers": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@types/archiver": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.2.tgz", - "integrity": "sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", "dev": true, "dependencies": { "@types/readdir-glob": "*" @@ -5768,9 +4880,9 @@ "dev": true }, "node_modules/@types/aws-lambda": { - "version": "8.10.122", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.122.tgz", - "integrity": "sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==" + "version": "8.10.143", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.143.tgz", + "integrity": "sha512-u5vzlcR14ge/4pMTTMDQr3MF0wEe38B2F9o84uC4F43vN5DGTy63npRrB6jQhyt+C0lGv4ZfiRcRkqJoZuPnmg==" }, "node_modules/@types/bcrypt": { "version": "5.0.2", @@ -5860,24 +4972,19 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", - "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.21", @@ -5904,14 +5011,19 @@ } }, "node_modules/@types/fluent-ffmpeg": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz", - "integrity": "sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==", + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.26.tgz", + "integrity": "sha512-0JVF3wdQG+pN0ImwWD0bNgJiKF2OHg/7CDBHw5UIbRTvlnkgGHK6V5doE54ltvhud4o31/dEiHm23CAlxFiUQg==", "dev": true, "dependencies": { "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, "node_modules/@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", @@ -5937,12 +5049,13 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", + "integrity": "sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==", "dev": true }, "node_modules/@types/luxon": { @@ -5980,34 +5093,34 @@ } }, "node_modules/@types/multer": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", - "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", "dev": true, "dependencies": { "@types/express": "*" } }, "node_modules/@types/mysql": { - "version": "2.15.22", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.22.tgz", - "integrity": "sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==", + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", "dependencies": { "@types/node": "*" } }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "22.8.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", + "integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.8" } }, "node_modules/@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -6020,9 +5133,9 @@ "dev": true }, "node_modules/@types/pg": { - "version": "8.10.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", - "integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -6030,23 +5143,23 @@ } }, "node_modules/@types/pg-pool": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.4.tgz", - "integrity": "sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", "dependencies": { "@types/pg": "*" } }, "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", - "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", - "postgres-date": "~2.0.1", + "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" }, @@ -6074,9 +5187,9 @@ } }, "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", - "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", "engines": { "node": ">=12" } @@ -6090,20 +5203,26 @@ } }, "node_modules/@types/picomatch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.0.tgz", - "integrity": "sha512-iX/Qwk9vU17N/5Q7QrV46wzciloTdCqTRt6z8A7uFFADM2+Sy5oQh9ldZhAiTXH+l0sM/EkXatEhJIs8FUyOBQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "node_modules/@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true }, "node_modules/@types/qs": { "version": "6.9.8", @@ -6118,22 +5237,15 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -6143,11 +5255,6 @@ "@types/node": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6176,9 +5283,9 @@ } }, "node_modules/@types/shimmer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.5.tgz", - "integrity": "sha512-9Hp0ObzwwO57DpLFF0InUjUm/II8GmKAvzbefxQTihCb7KI6yc9yzf0nLc4mVdby5N4DRCgQM2wCup9KTieeww==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" }, "node_modules/@types/ssh2": { "version": "0.5.52", @@ -6248,42 +5355,32 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "node_modules/@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "dependencies": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", - "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/type-utils": "7.17.0", - "@typescript-eslint/utils": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -6292,26 +5389,26 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -6320,16 +5417,16 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", - "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -6337,26 +5434,23 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", - "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.56.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -6364,12 +5458,12 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", - "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -6377,22 +5471,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", - "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -6429,63 +5523,58 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", - "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", - "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz", + "integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -6495,10 +5584,67 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.3", + "vitest": "2.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/coverage-v8/node_modules/magic-string": { + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", @@ -6507,25 +5653,10 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", - "dev": true, - "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", "dev": true, "dependencies": { "tinyrainbow": "^1.2.0" @@ -6535,12 +5666,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", "dev": true, "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.3", "pathe": "^1.1.2" }, "funding": { @@ -6548,13 +5679,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -6571,9 +5702,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, "dependencies": { "tinyspy": "^3.0.0" @@ -6583,13 +5714,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -6601,6 +5731,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -6609,22 +5740,26 @@ "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6634,12 +5769,14 @@ "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6651,6 +5788,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6659,6 +5797,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6666,12 +5805,14 @@ "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6687,6 +5828,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -6699,6 +5841,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -6710,6 +5853,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6723,6 +5867,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -6731,12 +5876,14 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "node_modules/abbrev": { "version": "1.1.1", @@ -6789,6 +5936,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -7091,17 +6239,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -7118,15 +6255,6 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "dev": true }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -7155,38 +6283,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "node_modules/autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -7331,9 +6427,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "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==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -7343,7 +6439,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -7468,15 +6564,15 @@ } }, "node_modules/bullmq": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz", - "integrity": "sha512-URnHgB01rlCP8RTpmW3kFnvv3vdd2aI1OcBMYQwnqODxGiJUlz9MibDVXE83mq7ee1eS1IvD9lMQqGszX6E5Pw==", + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz", + "integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==", "dependencies": { "cron-parser": "^4.6.0", "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", - "msgpackr": "^1.10.1", + "msgpackr": "^1.6.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", @@ -7495,6 +6591,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -7587,6 +6684,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true, "engines": { "node": ">= 6" } @@ -7690,6 +6788,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, "engines": { "node": ">=6.0" } @@ -7710,9 +6809,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" + "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==" }, "node_modules/class-transformer": { "version": "0.5.1", @@ -7854,14 +6953,6 @@ "node": ">=0.8" } }, - "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", - "engines": { - "node": ">=6" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -7924,9 +7015,9 @@ } }, "node_modules/comment-json": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.3.tgz", - "integrity": "sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", "dev": true, "dependencies": { "array-timsort": "^1.0.3", @@ -8063,17 +7154,25 @@ } }, "node_modules/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "dependencies": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, "engines": { "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==", + "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", @@ -8237,6 +7336,14 @@ "node": ">=12.0.0" } }, + "node_modules/cron/node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -8254,6 +7361,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true, "bin": { "cssesc": "bin/cssesc" }, @@ -8264,7 +7372,8 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true }, "node_modules/dayjs": { "version": "1.11.10", @@ -8283,11 +7392,11 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -8310,7 +7419,8 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -8386,11 +7496,6 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "node_modules/diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -8399,7 +7504,8 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "node_modules/diff": { "version": "4.0.2", @@ -8411,18 +7517,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -8432,7 +7526,8 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "node_modules/docker-compose": { "version": "0.24.8", @@ -8509,17 +7604,6 @@ "node": ">=6" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8582,14 +7666,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "engines": { - "node": ">=12" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -8658,9 +7734,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8675,9 +7751,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8688,24 +7764,12 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" } }, - "node_modules/engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, "node_modules/engine.io-parser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", @@ -8715,9 +7779,10 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -8767,12 +7832,13 @@ "node_modules/es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -8782,29 +7848,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@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" } }, "node_modules/escalade": { @@ -8824,6 +7890,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "engines": { "node": ">=10" }, @@ -8832,57 +7899,63 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "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" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -8897,17 +7970,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "dependencies": { - "eslint-plugin-turbo": "1.10.12" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -8938,25 +8000,6 @@ } } }, - "node_modules/eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "dependencies": { - "dotenv": "16.0.3" - }, - "peerDependencies": { - "eslint": ">6.6.0" - } - }, - "node_modules/eslint-plugin-turbo/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" - } - }, "node_modules/eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -8990,28 +8033,17 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "engines": { - "node": ">=18" - }, - "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.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, "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" @@ -9021,6 +8053,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9028,10 +8061,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "node_modules/eslint/node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9043,10 +8083,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -9057,19 +8110,33 @@ "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "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.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.1.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.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -9092,6 +8159,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -9103,6 +8171,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -9114,6 +8183,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -9131,6 +8201,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9164,133 +8235,71 @@ "node": ">=0.8.x" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", + "integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "dependencies": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0" + "exiftool-vendored.exe": "12.97.0", + "exiftool-vendored.pl": "12.97.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", + "integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "optional": true, "os": [ "win32" ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", + "integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "optional": true, "os": [ "!win32" ] }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -9302,9 +8311,9 @@ } }, "node_modules/express/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -9323,9 +8332,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/extend": { "version": "3.0.2", @@ -9348,7 +8357,8 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", @@ -9379,12 +8389,14 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -9422,14 +8434,15 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-source": { @@ -9452,12 +8465,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -9485,6 +8498,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -9497,55 +8511,23 @@ } }, "node_modules/flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "dependencies": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "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/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", @@ -9626,40 +8608,10 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "dependencies": { - "tslib": "^2.4.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==" }, "node_modules/fresh": { "version": "0.5.2", @@ -9830,17 +8782,17 @@ } }, "node_modules/geo-tz": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.0.2.tgz", - "integrity": "sha512-NjEzJBzaMhO9C7lFZIsWDkVED7aLxcES3iEZOWJ97dhnDUGhEB8vhW7MaWR+2y4aWvtFV/VyuDi8Y0rUHvm4tw==", + "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==", "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", + "@turf/boolean-point-in-polygon": "^7.1.0", + "@turf/helpers": "^7.1.0", "geobuf": "^3.0.2", "pbf": "^3.2.1" }, "engines": { - "node": ">=12" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/evansiroky" @@ -9869,15 +8821,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -9896,14 +8839,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -9928,18 +8863,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -9976,7 +8899,8 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", @@ -10018,34 +8942,12 @@ } }, "node_modules/globals": { - "version": "13.22.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz", - "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10076,7 +8978,8 @@ "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "node_modules/handlebars": { "version": "4.7.8", @@ -10271,19 +9174,10 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/i18n-iso-countries": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.3.tgz", - "integrity": "sha512-yxQVzNvxEaspSqNnCbqLvwTZNXXkGydWcSxytJYZYb0KH5pn13fdywuX0vFxmOg57Z8ff416AuKDx6Oqnx+j9w==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.12.0.tgz", + "integrity": "sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==", "dependencies": { "diacritics": "1.3.0" }, @@ -10325,6 +9219,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, "engines": { "node": ">= 4" } @@ -10345,9 +9240,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.0.tgz", - "integrity": "sha512-/xQjze8szLNnJ5rvHSzn+dcVXqCAU6Plbk4P24U/jwPmg1wy7IIp9OjKIO5tYue8GSPhDpPDiApQjvBUmWwhsQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", "dependencies": { "acorn": "^8.8.2", "acorn-import-attributes": "^1.9.5", @@ -10359,6 +9254,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -10416,14 +9312,6 @@ "node": ">=12.0.0" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -10540,14 +9428,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "engines": { - "node": ">=8" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -10674,6 +9554,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10691,9 +9572,9 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -10787,7 +9668,8 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -10803,7 +9685,8 @@ "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { "version": "2.2.3", @@ -10835,9 +9718,10 @@ } }, "node_modules/keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "dependencies": { "json-buffer": "3.0.1" } @@ -10892,6 +9776,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -10909,6 +9794,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true, "engines": { "node": ">=10" } @@ -10931,6 +9817,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, "engines": { "node": ">=6.11.5" } @@ -10939,6 +9826,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -10972,7 +9860,8 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -11006,13 +9895,10 @@ } }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -11023,9 +9909,9 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "engines": { "node": ">=12" } @@ -11125,14 +10011,18 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/merge2": { "version": "1.4.1", @@ -11151,11 +10041,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -11292,18 +10182,10 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.0.tgz", + "integrity": "sha512-3ROPnEMgBOkusBMYQUW2rnT3wZwsgfOKzJDLvx/TZ7FL1WmWvwSwn3j4aDR5fLDGtgcc1WF0Z1y0di7c9L4FKw==", "dev": true, "engines": { "node": ">=12.0.0" @@ -11329,9 +10211,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/msgpackr": { "version": "1.10.1", @@ -11462,7 +10344,8 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/nearley": { "version": "2.20.1", @@ -11506,9 +10389,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "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==", "dependencies": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -11542,9 +10425,9 @@ } }, "node_modules/nestjs-cls": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.3.0.tgz", - "integrity": "sha512-MVTun6tqCZih8AJXRj8uBuuFyJhQrIA9m9fStiQjbBXUkE3BrlMRvmLzyw8UcneB3xtFFTfwkAh5PYKRulyaOg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.1.tgz", + "integrity": "sha512-4yhldwm/cJ02lQ8ZAdM8KQ7gMfjAc1z3fo5QAQgXNyN4N6X5So9BCwv+BTLRugDCkELUo3qtzQHnKhGYL/ftPg==", "engines": { "node": ">=16" }, @@ -11570,12 +10453,12 @@ } }, "node_modules/next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "dependencies": { - "@next/env": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -11589,18 +10472,19 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4" + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -11609,6 +10493,9 @@ "@opentelemetry/api": { "optional": true }, + "@playwright/test": { + "optional": true + }, "sass": { "optional": true } @@ -11691,9 +10578,9 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "node_modules/nodemailer": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", - "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", "engines": { "node": ">=6.0.0" } @@ -11741,46 +10628,11 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -11809,18 +10661,16 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -11876,11 +10726,11 @@ } }, "node_modules/openid-client": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", - "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", "dependencies": { - "jose": "^4.15.5", + "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -11917,6 +10767,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "dependencies": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -11963,6 +10814,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -11977,6 +10829,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -12071,6 +10924,7 @@ "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" } @@ -12129,9 +10983,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "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==" }, "node_modules/path-type": { "version": "4.0.0", @@ -12177,13 +11031,13 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -12209,9 +11063,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -12230,17 +11084,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "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==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -12266,14 +11120,15 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", "engines": { "node": ">=12" }, @@ -12285,6 +11140,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12293,6 +11149,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true, "engines": { "node": ">= 6" } @@ -12306,10 +11163,25 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/point-in-polygon-hao": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", + "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" + }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -12326,8 +11198,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12337,6 +11209,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -12353,6 +11226,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -12381,6 +11255,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -12405,6 +11280,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true, "engines": { "node": ">=14" }, @@ -12416,6 +11292,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -12434,6 +11311,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12445,7 +11323,8 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "node_modules/postgres-array": { "version": "2.0.0", @@ -12483,14 +11362,15 @@ } }, "node_modules/postgres-range": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", - "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -12522,45 +11402,21 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } } }, - "node_modules/prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prism-react-renderer/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "engines": { - "node": ">=6" - } - }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -12633,9 +11489,9 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==" }, "node_modules/protobufjs": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.2.tgz", - "integrity": "sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", "hasInstallScript": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", @@ -12686,16 +11542,17 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -12751,6 +11608,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -12792,6 +11650,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12801,53 +11660,27 @@ } }, "node_modules/react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "dependencies": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "bin": { - "email": "cli/index.js" + "email": "dist/cli/index.js" }, "engines": { "node": ">=18.0.0" @@ -13198,208 +12031,6 @@ "node": ">=12" } }, - "node_modules/react-email/node_modules/@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "hasInstallScript": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/react-email/node_modules/@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/react-email/node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "node_modules/react-email/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -13408,32 +12039,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/react-email/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/react-email/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -13479,17 +12084,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "node_modules/react-email/node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/react-email/node_modules/glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -13525,90 +12119,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/react-email/node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/react-email/node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/react-email/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -13622,77 +12132,11 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" }, - "node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "dependencies": { "pify": "^2.3.0" } @@ -13885,11 +12329,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -14165,9 +12604,9 @@ } }, "node_modules/rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -14180,22 +12619,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -14273,6 +12712,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -14281,6 +12721,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -14298,6 +12739,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14313,6 +12755,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -14320,7 +12763,8 @@ "node_modules/schema-utils/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/selderee": { "version": "0.11.0", @@ -14345,9 +12789,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -14380,28 +12824,32 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "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==", + "engines": { + "node": ">= 0.8" + } }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -14468,42 +12916,41 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" }, "engines": { - "libvips": ">=8.15.2", "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4" + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" } }, "node_modules/shebang-command": { @@ -14531,13 +12978,17 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14574,25 +13025,16 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/slice-source": { @@ -14626,40 +13068,6 @@ "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -14672,15 +13080,6 @@ "node": ">=10.0.0" } }, - "node_modules/sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -14691,9 +13090,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -14702,6 +13101,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -14711,6 +13111,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14762,9 +13163,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.5.tgz", + "integrity": "sha512-dxYn0OzEmB19/9Y+yh8bqD8kJx2S/4pOTM4QLKxQDh7K6lp1Sx9MhmiF9RUJHSVjfV72KihW5R1h6Kecy6O5qA==", "dev": true, "dependencies": { "argparse": "^2.0.1", @@ -14809,25 +13210,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "node_modules/stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "engines": { - "node": ">=8" - } - }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -14931,18 +13313,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -14959,6 +13329,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "engines": { "node": ">=8" }, @@ -14992,6 +13363,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -15086,18 +13458,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "dependencies": { - "@babel/runtime": "^7.23.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -15188,14 +13548,15 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -15260,6 +13621,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -15277,6 +13639,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -15310,6 +13673,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -15323,6 +13687,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15336,7 +13701,8 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/test-exclude": { "version": "7.0.1", @@ -15377,9 +13743,9 @@ } }, "node_modules/testcontainers": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.11.0.tgz", - "integrity": "sha512-TYgpR+MjZSuX7kSUxTa0f/CsN6eErbMFrAFumW08IvOnU8b+EoRzpzEu7mF0d29M1ItnHfHPUP44HYiE4yP3Zg==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.2.tgz", + "integrity": "sha512-LfEll+AG/1Ks3n4+IA5lpyBHLiYh/hSfI4+ERa6urwfQscbDU+M2iW1qPQrHQi+xJXQRYy4whyK1IEHdmxWa3Q==", "dev": true, "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -15425,7 +13791,8 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thenify": { "version": "3.3.1", @@ -15457,15 +13824,21 @@ "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==" }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true }, "node_modules/tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", "dev": true, "engines": { "node": "^18.0.0 || >=20.0.0" @@ -15481,9 +13854,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "engines": { "node": ">=14.0.0" @@ -15572,7 +13945,8 @@ "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "node_modules/ts-node": { "version": "10.9.2", @@ -15676,9 +14050,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tweetnacl": { "version": "0.14.5", @@ -15690,6 +14064,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -15697,17 +14072,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -15914,9 +14278,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "bin": { "tsc": "bin/tsc", @@ -15927,9 +14291,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -15944,6 +14308,9 @@ "url": "https://github.com/sponsors/faisalman" } ], + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -15992,9 +14359,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/universalify": { "version": "2.0.0", @@ -16075,51 +14442,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -16188,9 +14515,10 @@ } }, "node_modules/validator": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", - "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -16204,14 +14532,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -16230,6 +14558,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -16247,6 +14576,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -16259,15 +14591,14 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -16281,9 +14612,9 @@ } }, "node_modules/vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, "dependencies": { "debug": "^4.1.1", @@ -16300,29 +14631,29 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -16337,8 +14668,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", "happy-dom": "*", "jsdom": "*" }, @@ -16364,9 +14695,9 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -16376,6 +14707,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -16398,11 +14730,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -16411,7 +14743,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -16456,6 +14788,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -16470,6 +14803,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16482,6 +14816,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -16574,15 +14909,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -16593,14 +14928,6 @@ } } }, - "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==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16677,6 +15004,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, @@ -16740,12 +15068,14 @@ "@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==" + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "peer": true }, "@ampproject/remapping": { "version": "2.3.0", @@ -17083,14 +15413,6 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==" }, - "@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "requires": { - "regenerator-runtime": "^0.14.0" - } - }, "@babel/template": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.6.tgz", @@ -17190,187 +15512,172 @@ } }, "@emnapi/runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.1.1.tgz", - "integrity": "sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", + "integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==", "optional": true, "requires": { "tslib": "^2.4.0" } }, - "@emotion/is-prop-valid": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", - "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "optional": true, - "requires": { - "@emotion/memoize": "0.7.4" - } - }, - "@emotion/memoize": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", - "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true - }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "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==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "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==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "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==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "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==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "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==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "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==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -17378,24 +15685,44 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "requires": { "eslint-visitor-keys": "^3.3.0" } }, "@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==" + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true + }, + "@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "requires": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + } + }, + "@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true }, "@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -17407,6 +15734,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -17414,17 +15742,40 @@ "uri-js": "^4.2.2" } }, + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, "@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==" + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true + }, + "@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true + }, + "@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "requires": { + "levn": "^0.4.1" + } }, "@fastify/busboy": { "version": "2.1.1", @@ -17432,36 +15783,6 @@ "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dev": true }, - "@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", - "requires": { - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", - "requires": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" - } - }, - "@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", - "requires": { - "@floating-ui/dom": "^1.0.0" - } - }, - "@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==" - }, "@golevelup/nestjs-discovery": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.1.tgz", @@ -17471,9 +15792,9 @@ } }, "@grpc/grpc-js": { - "version": "1.10.10", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.10.10.tgz", - "integrity": "sha512-HPa/K5NX6ahMoeBv15njAc/sfF4/jmiXLar9UlC2UfHFKZzsCVLc3wbe7+7qua7w9VPh2/L6EBxyAV7/E8Wftg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz", + "integrity": "sha512-bgxdZmgTrJZX50OjyVwz3+mNEnCTNkh3cIqGPWVNeW9jX6bn1ZkU80uPd+67/ZpIJIjRQ9qaHCjhavyoWYxumg==", "requires": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" @@ -17539,165 +15860,173 @@ "@hapi/hoek": "^9.0.0" } }, - "@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true + }, + "@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, "requires": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" } }, "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true }, - "@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==" + "@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true }, "@img/sharp-darwin-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.4.tgz", - "integrity": "sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.2" + "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, "@img/sharp-darwin-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.4.tgz", - "integrity": "sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.2" + "@img/sharp-libvips-darwin-x64": "1.0.4" } }, "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "optional": true }, "@img/sharp-libvips-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz", - "integrity": "sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "optional": true }, "@img/sharp-libvips-linux-arm": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz", - "integrity": "sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "optional": true }, "@img/sharp-libvips-linux-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz", - "integrity": "sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "optional": true }, "@img/sharp-libvips-linux-s390x": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz", - "integrity": "sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "optional": true }, "@img/sharp-libvips-linux-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz", - "integrity": "sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "optional": true }, "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz", - "integrity": "sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "optional": true }, "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz", - "integrity": "sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "optional": true }, "@img/sharp-linux-arm": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.4.tgz", - "integrity": "sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm": "1.0.2" + "@img/sharp-libvips-linux-arm": "1.0.5" } }, "@img/sharp-linux-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.4.tgz", - "integrity": "sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.2" + "@img/sharp-libvips-linux-arm64": "1.0.4" } }, "@img/sharp-linux-s390x": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.4.tgz", - "integrity": "sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "optional": true, "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.2" + "@img/sharp-libvips-linux-s390x": "1.0.4" } }, "@img/sharp-linux-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.4.tgz", - "integrity": "sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-x64": "1.0.2" + "@img/sharp-libvips-linux-x64": "1.0.4" } }, "@img/sharp-linuxmusl-arm64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.4.tgz", - "integrity": "sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" } }, "@img/sharp-linuxmusl-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.4.tgz", - "integrity": "sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.2" + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "@img/sharp-wasm32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.4.tgz", - "integrity": "sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "optional": true, "requires": { - "@emnapi/runtime": "^1.1.1" + "@emnapi/runtime": "^1.2.0" } }, "@img/sharp-win32-ia32": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.4.tgz", - "integrity": "sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "optional": true }, "@img/sharp-win32-x64": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz", - "integrity": "sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "optional": true }, "@ioredis/commands": { @@ -17793,6 +16122,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -17882,26 +16212,26 @@ "optional": true }, "@nestjs/bull-shared": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.1.1.tgz", - "integrity": "sha512-su7eThDrSz1oQagEi8l+1CyZ7N6nMgmyAX0DuZoXqT1KEVEDqGX7x80RlPVF60m/8SYOskckGMjJROSfNQcErw==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-10.2.1.tgz", + "integrity": "sha512-zvnTvSq6OJ92omcsFUwaUmPbM3PRgWkIusHPB5TE3IFS7nNdM3OwF+kfe56sgKjMtQQMe/56lok0S04OtPMX5Q==", "requires": { - "tslib": "2.6.2" + "tslib": "2.6.3" } }, "@nestjs/bullmq": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.1.1.tgz", - "integrity": "sha512-afYx1wYCKtXEu1p0S1+qw2o7QaZWr/EQgF7Wkt3YL8RBIECy5S4C450gv/cRGd8EZjlt6bw8hGCLqR2Q5VjHpQ==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-10.2.1.tgz", + "integrity": "sha512-nDR0hDabmtXt5gsb5R786BJsGIJoWh/79sVmRETXf4S45+fvdqG1XkCKAeHF9TO9USodw9m+XBNKysTnkY41gw==", "requires": { - "@nestjs/bull-shared": "^10.1.1", - "tslib": "2.6.2" + "@nestjs/bull-shared": "^10.2.1", + "tslib": "2.6.3" } }, "@nestjs/cli": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.2.tgz", - "integrity": "sha512-fQexIfLHfp6GUgX+CO4fOg+AEwV5ox/LHotQhyZi9wXUQDyIqS0NTTbumr//62EcX35qV4nU0359nYnuEdzG+A==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", + "integrity": "sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -17921,7 +16251,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.92.1", + "webpack": "5.94.0", "webpack-node-externals": "3.0.0" }, "dependencies": { @@ -17934,56 +16264,46 @@ } }, "@nestjs/common": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.10.tgz", - "integrity": "sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.6.tgz", + "integrity": "sha512-KkezkZvU9poWaNq4L+lNvx+386hpOxPJkfXBBeSMrcqBOx8kVr36TGN2uYkF4Ta4zNu1KbCjmZbc0rhHSg296g==", "requires": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "dependencies": { "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" } } }, - "@nestjs/config": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", - "integrity": "sha512-p6yv/CvoBewJ72mBq4NXgOAi2rSQNWx3a+IMJLVKS2uiwFCOQQuiIatGwq6MRjXV3Jr+B41iUO8FIf4xBrZ4/w==", - "requires": { - "dotenv": "16.4.5", - "dotenv-expand": "10.0.0", - "lodash": "4.17.21" - } - }, "@nestjs/core": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.10.tgz", - "integrity": "sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.6.tgz", + "integrity": "sha512-zXVPxCNRfO6gAy0yvEDjUxE/8gfZICJFpsl2lZAUH31bPb6m+tXuhUq2mVCTEltyMYQ+DYtRe+fEYM2v152N1g==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "dependencies": { "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" } } }, "@nestjs/event-emitter": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", - "integrity": "sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz", + "integrity": "sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==", "requires": { "eventemitter2": "6.4.9" } @@ -17995,44 +16315,80 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.10.tgz", - "integrity": "sha512-wK2ow3CZI2KFqWeEpPmoR300OB6BcBLxARV1EiClJLCj4S1mZsoCmS0YWgpk3j1j6mo0SI8vNLi/cC2iZPEPQA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.6.tgz", + "integrity": "sha512-HcyCpAKccAasrLSGRTGWv5BKRs0rwTIFOSsk6laNyqfqvgvYcJQAedarnm4jmaemtmSJ0PFI9PmtEZADd2ahCg==", "requires": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.1", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "dependencies": { "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" } } }, "@nestjs/platform-socket.io": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.10.tgz", - "integrity": "sha512-LRd+nGWhUu9hND1txCLPZd78Hea+qKJVENb+c9aDU04T24GRjsInDF2RANMR16JLQFcI9mclktDWX4plE95SHg==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.6.tgz", + "integrity": "sha512-lGv99O7C00wtnGq9M0mcwrOpH2qmuqAXQyvo/d/I7rmaf3OO1Sg8qWDLAnPKHdaumwOL2mnET3kvCJ06MaL6WA==", "requires": { - "socket.io": "4.7.5", - "tslib": "2.6.3" + "socket.io": "4.8.0", + "tslib": "2.7.0" }, "dependencies": { + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "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==", + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + } + }, + "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==", + "requires": { + "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" + } + }, "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" } } }, "@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "requires": { "cron": "3.1.7", "uuid": "10.0.0" @@ -18046,52 +16402,101 @@ } }, "@nestjs/schematics": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.3.tgz", - "integrity": "sha512-aLJ4Nl/K/u6ZlgLa0NjKw5CuBOIgc6vudF42QvmGueu5FaMGM6IJrAuEvB5T2kr0PAfVwYmDFBBHCWdYhTw4Tg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.2.tgz", + "integrity": "sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==", "dev": true, "requires": { - "@angular-devkit/core": "17.3.8", - "@angular-devkit/schematics": "17.3.8", - "comment-json": "4.2.3", + "@angular-devkit/core": "17.3.10", + "@angular-devkit/schematics": "17.3.10", + "comment-json": "4.2.5", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "dependencies": { + "@angular-devkit/core": { + "version": "17.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.10.tgz", + "integrity": "sha512-czdl54yxU5DOAGy/uUPNjJruoBDTgwi/V+eOgLNybYhgrc+TsY0f7uJ11yEk/pz5sCov7xIiS7RdRv96waS7vg==", + "dev": true, + "requires": { + "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" + }, + "dependencies": { + "jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + } + } + }, + "@angular-devkit/schematics": { + "version": "17.3.10", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.10.tgz", + "integrity": "sha512-FHcNa1ktYRd0SKExCsNJpR75RffsyuPIV8kvBXzXnLHmXMqvl25G2te3yYJ9yYqy9OLy/58HZznZTxWRyUdHOg==", + "dev": true, + "requires": { + "@angular-devkit/core": "17.3.10", + "jsonc-parser": "3.2.1", + "magic-string": "0.30.8", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "dependencies": { + "jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "dev": true + } + } + }, "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==", "dev": true + }, + "picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "dev": true } } }, "@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "requires": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" } }, "@nestjs/testing": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.10.tgz", - "integrity": "sha512-i3HAtVQJijxNxJq1k39aelyJlyEIBRONys7IipH/4r8W0J+M1V+y5EKDOyi4j1SdNSb/vmNyWpZ2/ewZjl3kRA==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.6.tgz", + "integrity": "sha512-aiDicKhlGibVGNYuew399H5qZZXaseOBT/BS+ERJxxCmco7ZdAqaujsNjSaSbTK9ojDPf27crLT0C4opjqJe3A==", "dev": true, "requires": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "dependencies": { "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true } } @@ -18105,79 +16510,79 @@ } }, "@nestjs/websockets": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.10.tgz", - "integrity": "sha512-F/fhAC0ylAhjfCZj4Xrgc0yTJ/qltooDCa+fke7BFZLofLmE0yj7WzBVrBHsk/46kppyRcs5XrYjIQLqcDze8g==", + "version": "10.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.6.tgz", + "integrity": "sha512-53YqDQylPAOudNFiiBvrN8QrRl/sZ9oEjKbD3wBVgrFREbaiuTySoyyy6HwVs60HW29uQwck+Bp7qkKGjhtQKg==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "dependencies": { "tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" } } }, "@next/env": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.4.tgz", - "integrity": "sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==" + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" }, "@next/swc-darwin-arm64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.4.tgz", - "integrity": "sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", "optional": true }, "@next/swc-darwin-x64": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.4.tgz", - "integrity": "sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.4.tgz", - "integrity": "sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.4.tgz", - "integrity": "sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.4.tgz", - "integrity": "sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.4.tgz", - "integrity": "sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.4.tgz", - "integrity": "sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", "optional": true }, "@next/swc-win32-ia32-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.4.tgz", - "integrity": "sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.4.tgz", - "integrity": "sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", "optional": true }, "@nodelib/fs.scandir": { @@ -18224,211 +16629,175 @@ "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==" }, "@opentelemetry/api-logs": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.0.tgz", - "integrity": "sha512-HxjD7xH9iAE4OyhNaaSec65i1H6QZYBWSwWkowFfsc5YAcDvJG30/J1sRKXEQqdmUcKTXEAnA66UciqZha/4+Q==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.54.0.tgz", + "integrity": "sha512-9HhEh5GqFrassUndqJsyW7a0PzfyWr2eV2xwzHLIS+wX3125+9HE9FMRAKmJRwxZhgZGwH3HNQQjoMGZqmOeVA==", "requires": { - "@opentelemetry/api": "^1.0.0" + "@opentelemetry/api": "^1.3.0" } }, "@opentelemetry/auto-instrumentations-node": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.48.0.tgz", - "integrity": "sha512-meON9LM9dyPun8ZlIs90BzqHAIWfWkC8g+OoPuIEeV5UOSyKqMsWtbMyiTbs/k/i7k1V4miJQMX/PcLbD7pWcQ==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.52.0.tgz", + "integrity": "sha512-J9SgX7NOpTvQ7itvlOlHP3lTlsMWtVh5WQSHUSTlg2m3A9HlZBri2DtZ8QgNj8rYWe0EQxQ3TQ3H6vabfun4vw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.39.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.0", - "@opentelemetry/instrumentation-bunyan": "^0.40.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", - "@opentelemetry/instrumentation-connect": "^0.38.0", - "@opentelemetry/instrumentation-cucumber": "^0.8.0", - "@opentelemetry/instrumentation-dataloader": "^0.11.0", - "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.0", - "@opentelemetry/instrumentation-fastify": "^0.38.0", - "@opentelemetry/instrumentation-fs": "^0.14.0", - "@opentelemetry/instrumentation-generic-pool": "^0.38.0", - "@opentelemetry/instrumentation-graphql": "^0.42.0", - "@opentelemetry/instrumentation-grpc": "^0.52.0", - "@opentelemetry/instrumentation-hapi": "^0.40.0", - "@opentelemetry/instrumentation-http": "^0.52.0", - "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-knex": "^0.38.0", - "@opentelemetry/instrumentation-koa": "^0.42.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", - "@opentelemetry/instrumentation-memcached": "^0.38.0", - "@opentelemetry/instrumentation-mongodb": "^0.46.0", - "@opentelemetry/instrumentation-mongoose": "^0.40.0", - "@opentelemetry/instrumentation-mysql": "^0.40.0", - "@opentelemetry/instrumentation-mysql2": "^0.40.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.39.0", - "@opentelemetry/instrumentation-net": "^0.38.0", - "@opentelemetry/instrumentation-pg": "^0.43.0", - "@opentelemetry/instrumentation-pino": "^0.41.0", - "@opentelemetry/instrumentation-redis": "^0.41.0", - "@opentelemetry/instrumentation-redis-4": "^0.41.0", - "@opentelemetry/instrumentation-restify": "^0.40.0", - "@opentelemetry/instrumentation-router": "^0.39.0", - "@opentelemetry/instrumentation-socket.io": "^0.41.0", - "@opentelemetry/instrumentation-tedious": "^0.12.0", - "@opentelemetry/instrumentation-undici": "^0.4.0", - "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.28.10", - "@opentelemetry/resource-detector-aws": "^1.5.2", - "@opentelemetry/resource-detector-azure": "^0.2.9", - "@opentelemetry/resource-detector-container": "^0.3.11", - "@opentelemetry/resource-detector-gcp": "^0.29.10", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/instrumentation-amqplib": "^0.43.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.46.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.45.0", + "@opentelemetry/instrumentation-bunyan": "^0.42.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.42.0", + "@opentelemetry/instrumentation-connect": "^0.40.0", + "@opentelemetry/instrumentation-cucumber": "^0.10.0", + "@opentelemetry/instrumentation-dataloader": "^0.13.0", + "@opentelemetry/instrumentation-dns": "^0.40.0", + "@opentelemetry/instrumentation-express": "^0.44.0", + "@opentelemetry/instrumentation-fastify": "^0.41.0", + "@opentelemetry/instrumentation-fs": "^0.16.0", + "@opentelemetry/instrumentation-generic-pool": "^0.40.0", + "@opentelemetry/instrumentation-graphql": "^0.44.0", + "@opentelemetry/instrumentation-grpc": "^0.54.0", + "@opentelemetry/instrumentation-hapi": "^0.42.0", + "@opentelemetry/instrumentation-http": "^0.54.0", + "@opentelemetry/instrumentation-ioredis": "^0.44.0", + "@opentelemetry/instrumentation-kafkajs": "^0.4.0", + "@opentelemetry/instrumentation-knex": "^0.41.0", + "@opentelemetry/instrumentation-koa": "^0.44.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.41.0", + "@opentelemetry/instrumentation-memcached": "^0.40.0", + "@opentelemetry/instrumentation-mongodb": "^0.48.0", + "@opentelemetry/instrumentation-mongoose": "^0.43.0", + "@opentelemetry/instrumentation-mysql": "^0.42.0", + "@opentelemetry/instrumentation-mysql2": "^0.42.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.41.0", + "@opentelemetry/instrumentation-net": "^0.40.0", + "@opentelemetry/instrumentation-pg": "^0.47.0", + "@opentelemetry/instrumentation-pino": "^0.43.0", + "@opentelemetry/instrumentation-redis": "^0.43.0", + "@opentelemetry/instrumentation-redis-4": "^0.43.0", + "@opentelemetry/instrumentation-restify": "^0.42.0", + "@opentelemetry/instrumentation-router": "^0.41.0", + "@opentelemetry/instrumentation-socket.io": "^0.43.0", + "@opentelemetry/instrumentation-tedious": "^0.15.0", + "@opentelemetry/instrumentation-undici": "^0.7.0", + "@opentelemetry/instrumentation-winston": "^0.41.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.4", + "@opentelemetry/resource-detector-aws": "^1.7.0", + "@opentelemetry/resource-detector-azure": "^0.2.12", + "@opentelemetry/resource-detector-container": "^0.5.0", + "@opentelemetry/resource-detector-gcp": "^0.29.13", "@opentelemetry/resources": "^1.24.0", - "@opentelemetry/sdk-node": "^0.52.0" + "@opentelemetry/sdk-node": "^0.54.0" } }, "@opentelemetry/context-async-hooks": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.25.1.tgz", - "integrity": "sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.27.0.tgz", + "integrity": "sha512-CdZ3qmHCwNhFAzjTgHqrDQ44Qxcpz43cVxZRhOs+Ns/79ug+Mr84Bkb626bkJLkA3+BLimA5YAEVRlJC6pFb7g==", "requires": {} }, "@opentelemetry/core": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.0.tgz", - "integrity": "sha512-n0B3s8rrqGrasTgNkXLKXzN0fXo+6IYP7M5b7AMsrZM33f/y6DS6kJ0Btd7SespASWq8bgL3taLo0oe0vB52IQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", "requires": { - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/semantic-conventions": "1.27.0" + } + }, + "@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.54.0.tgz", + "integrity": "sha512-CQC9xl9p8EIvx2KggdM7yffbpmUArKjiqAcjTTTEvqE8kOOf71NSuBU0FXs14FU8vBGTUlsr3oI4vGeWF8FakA==", + "requires": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/sdk-logs": "0.54.0" + } + }, + "@opentelemetry/exporter-logs-otlp-http": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.54.0.tgz", + "integrity": "sha512-EX/5YPtFw5hugURWSmOtJEGsjphkwTRAiv2yay40ADCLEzajhI/tM3v/7hFCj+rm37sGFMNawpi3mGLvfKGexQ==", + "requires": { + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/sdk-logs": "0.54.0" + } + }, + "@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.54.0.tgz", + "integrity": "sha512-Q8p1eLP6BGu26VdiR8qBiyufXTZimUl2kv6EwZZPLRU0CJWAFR562UOyUtDxbwQioQFq57DVjCd6mQWBvydAlg==", + "requires": { + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-trace-base": "1.27.0" } }, "@opentelemetry/exporter-prometheus": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.52.1.tgz", - "integrity": "sha512-hwK0QnjtqAxGpQAXMNUY+kTT5CnHyz1I0lBA8SFySvaFtExZm7yQg/Ua/i+RBqgun7WkUbkUVJzEi3lKpJ7WdA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.54.0.tgz", + "integrity": "sha512-httb+/c36hZvkIR9SqwXj+fLeE2XDdWfZqGO24MboNMHihmnvjE0/LN29I9CjsJqC2jEi8FErfQha/JeOfsFaA==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-metrics": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-metrics": "1.27.0" } }, "@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.52.1.tgz", - "integrity": "sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.54.0.tgz", + "integrity": "sha512-DOoK7yk/L/RHoyuYTxIyGY7PLFSTS7OGNks9htMS7eAFkm4Lsa6EtPlGANCT39NXWP4XIQR1c+Y+YIQ7lJdI+w==", "requires": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" } }, "@opentelemetry/exporter-trace-otlp-http": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.52.1.tgz", - "integrity": "sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.54.0.tgz", + "integrity": "sha512-00X6rtr6Ew59+MM9pPSH7Ww5ScpWKBLiBA49awbPqQuVL/Bp0qp7O1cTxKHgjWdNkhsELzJxAEYwuRnDGrMXyA==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" } }, "@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.52.1.tgz", - "integrity": "sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.54.0.tgz", + "integrity": "sha512-cpDQj5wl7G8pLu3lW94SnMpn0C85A9Ehe7+JBow2IL5DGPWXTkynFngMtCC3PpQzQgzlyOVe0MVZfoBB3M5ECA==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0" } }, "@opentelemetry/exporter-zipkin": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.25.1.tgz", - "integrity": "sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.27.0.tgz", + "integrity": "sha512-eGMY3s4QprspFZojqsuQyQpWNFpo+oNVE/aosTbtvAlrJBAlvXcwwsOROOHOd8Y9lkU4i0FpQW482rcXkgwCSw==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/host-metrics": { @@ -18441,297 +16810,305 @@ } }, "@opentelemetry/instrumentation": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.0.tgz", - "integrity": "sha512-LPwSIrw+60cheWaXsfGL8stBap/AppKQJFE+qqRvzYrgttXFH2ofoIMxWadeqPTq4BYOXM/C7Bdh/T+B60xnlQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.54.0.tgz", + "integrity": "sha512-B0Ydo9g9ehgNHwtpc97XivEzjz0XBKR6iQ83NTENIxEEf5NHE0otZQuZLgDdey1XNk+bP1cfRpIkSFWM5YlSyg==", "requires": { - "@opentelemetry/api-logs": "0.52.0", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.8.0", + "@opentelemetry/api-logs": "0.54.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" } }, "@opentelemetry/instrumentation-amqplib": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.39.0.tgz", - "integrity": "sha512-i9SccU5bbHivmmN8ba8HitLnM915BWdGwk5Jl6dwHTp0eV4KpoprZLE/jXUY1AAP/LXpTrM7NgVHmslFSVWRYA==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.43.0.tgz", + "integrity": "sha512-ALjfQC+0dnIEcvNYsbZl/VLh7D2P1HhFF4vicRKHhHFIUV3Shpg4kXgiek5PLhmeKSIPiUB25IYH5RIneclL4A==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-aws-lambda": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.43.0.tgz", - "integrity": "sha512-pSxcWlsE/pCWQRIw92sV2C+LmKXelYkjkA7C5s39iPUi4pZ2lA1nIiw+1R/y2pDEhUHcaKkNyljQr3cx9ZpVlQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.46.0.tgz", + "integrity": "sha512-rNmhTC1e1qQD4jw+TZSHlpLYNhrkbKA0P5rlqPpTVHqZXHQctu9+dity2lLBh4DlFKt4p/ibVDLVDoBqjvetKA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/propagator-aws-xray": "^1.3.1", - "@opentelemetry/resources": "^1.8.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/aws-lambda": "8.10.122" + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "8.10.143" } }, "@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.0.tgz", - "integrity": "sha512-klfA48MT0uZY/mGw3cYdQeCXTyMhtY4FzHcZy9R7DdTcuCExgbxWrUlOSiqIJ5kBgsCZfBMEeA6UQKDBwa6X7Q==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.45.0.tgz", + "integrity": "sha512-3EGgC0LFZuFfXcOeslhXHhsiInVhhN046YQsYIPflsicAk7v0wN946sZKWuerEfmqx/kFXOsbOeI1SkkTRmqWQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/propagation-utils": "^0.30.10", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/propagation-utils": "^0.30.12", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-bunyan": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.40.0.tgz", - "integrity": "sha512-aZ4cXaGWwj79ZXSYrgFVsrDlE4mmf2wfvP9bViwRc0j75A6eN6GaHYHqufFGMTCqASQn5pIjjP+Bx+PWTGiofw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.42.0.tgz", + "integrity": "sha512-GBh6ybwKmFZjc86SyHVx72jHg+4pFPaXT3IZgJ4QtnMsMf0/q5m2aHAjid+yakmEkApsnRWX8pJ8nkl1e+6mag==", "requires": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0", "@types/bunyan": "1.8.9" } }, "@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.40.0.tgz", - "integrity": "sha512-JxbM39JU7HxE9MTKKwi6y5Z3mokjZB2BjwfqYi4B3Y29YO3I42Z7eopG6qq06yWZc+nQli386UDQe0d9xKmw0A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.42.0.tgz", + "integrity": "sha512-35I9Gw4BeSs9NPe7fugu9e/mWKaapc/N1wounHnGt259/Q3ISGMOQRrOwIBw+x/XJygJvn4Ss1c+r5h89TsVAw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-connect": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.38.0.tgz", - "integrity": "sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.40.0.tgz", + "integrity": "sha512-3aR/3YBQ160siitwwRLjwqrv2KBT16897+bo6yz8wIfel6nWOxTZBJudcbsK3p42pTC7qrbotJ9t/1wRLpv79Q==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.36" } }, "@opentelemetry/instrumentation-cucumber": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.8.0.tgz", - "integrity": "sha512-ieTm4RBIlZt2brPwtX5aEZYtYnkyqhAVXJI9RIohiBVMe5DxiwCwt+2Exep/nDVqGPX8zRBZUl4AEw423OxJig==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.10.0.tgz", + "integrity": "sha512-5sT6Ap3W7StEL0Oax/vd1YTEcTPTefx+9myzkKrr72hxzFzSooGRCxlU3sfPwZqWptUV7+QWTMd7SqGEEPnE/w==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-dataloader": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.11.0.tgz", - "integrity": "sha512-27urJmwkH4KDaMJtEv1uy2S7Apk4XbN4AgWMdfMJbi7DnOduJmeuA+DpJCwXB72tEWXo89z5T3hUVJIDiSNmNw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.13.0.tgz", + "integrity": "sha512-wbU3WdgUAXljEIY2nfpkqID/VH70ThnES8mZZHKCZlV/Pl5T4+qmrVdT7U9/WUzz8flwsXfER6T6jl48Wbl+LQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-dns": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.38.0.tgz", - "integrity": "sha512-Um07I0TQXDWa+ZbEAKDFUxFH40dLtejtExDOMLNJ1CL8VmOmA71qx93Qi/QG4tGkiI1XWqr7gF/oiMCJ4m8buQ==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.40.0.tgz", + "integrity": "sha512-tLNR8XLPiYRKKk3/UqifXnPP2TVt1RcwvHU0R1ETL1xkZ1ZHMTmSC4x6TignnHOFtRixtJ05EgMGejnffaBXkQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "semver": "^7.5.4" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-express": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.0.tgz", - "integrity": "sha512-/B7fbMdaf3SYe5f1P973tkqd6s7XZirjpfkoJ63E7nltU30qmlgm9tY5XwZOzAFI0rHS9tbrFI2HFPAvQUFe/A==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.44.0.tgz", + "integrity": "sha512-GWgibp6Q0wxyFaaU8ERIgMMYgzcHmGrw3ILUtGchLtLncHNOKk0SNoWGqiylXWWT4HTn5XdV8MGawUgpZh80cA==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-fastify": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.38.0.tgz", - "integrity": "sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.41.0.tgz", + "integrity": "sha512-pNRjFvf0mvqfJueaeL/qEkuGJwgtE5pgjIHGYwjc2rMViNCrtY9/Sf+Nu8ww6dDd/Oyk2fwZZP7i0XZfCnETrA==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-fs": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.14.0.tgz", - "integrity": "sha512-pVc8P5AgliC1DphyyBUgsxXlm2XaPH4BpYvt7rAZDMIqUpRk8gs19SioABtKqqxvFzg5jPtgJfJsdxq0Y+maLw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.16.0.tgz", + "integrity": "sha512-hMDRUxV38ln1R3lNz6osj3YjlO32ykbHqVrzG7gEhGXFQfu7LJUx8t9tEwE4r2h3CD4D0Rw4YGDU4yF4mP3ilg==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-generic-pool": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.38.0.tgz", - "integrity": "sha512-0/ULi6pIco1fEnDPmmAul8ZoudFL7St0hjgBbWZlZPBCSyslDll1J7DFeEbjiRSSyUd+0tu73ae0DOKVKNd7VA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.40.0.tgz", + "integrity": "sha512-k+/JlNDHN3bPi/Cir+Ew6tKHFVCa1ZFeQyGUw5HQkRX/twCRaN3kJFXJW+rDAN90XwK3RtC9AWwBihDGh/oSlQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-graphql": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.42.0.tgz", - "integrity": "sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.44.0.tgz", + "integrity": "sha512-FYXTe3Bv96aNpYktqm86BFUTpjglKD0kWI5T5bxYkLUPEPvFn38vWGMJTGrDMVou/i55E4jlWvcm6hFIqLsMbg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-grpc": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.52.0.tgz", - "integrity": "sha512-YYhA2pbhMWgF5Hp6eR7AHp1utzZQ3Y0VB8GIwd8zJoLtAuQRZa1N29DUtZ+t/pGRJF+xGPVI+vP+7ugHgeN0zQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.54.0.tgz", + "integrity": "sha512-IwLwAf1uC6I5lYjUxfvG0jFuppqNuaBIiaDxYFHMWeRX1Rejh4eqtQi2u+VVtSOHsCn2sRnS9hOxQ2w7+zzPLw==", "requires": { - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/instrumentation-hapi": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.40.0.tgz", - "integrity": "sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.42.0.tgz", + "integrity": "sha512-TQC0BtIWLHrp6nKsYdZ5t5B7aiZ16BwbRqZtYYQxeJVsq/HQTANWpknjtA7KMxv5tAUMCrU/eDo8F3qioUOSZg==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-http": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.52.0.tgz", - "integrity": "sha512-E6ywZuxTa4LnVXZGwL1oj3e2Eog1yIaNqa8KjKXoGkDNKte9/SjQnePXOmhQYI0A9nf0UyFbP9aKd+yHrkJXUA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.54.0.tgz", + "integrity": "sha512-ovl0UrL+vGpi0O7fdZ1mHRdiQkuv6NGMRBRKZZygVCUFNXdoqTpvJRRbTYih5U5FC+PHIFssEordmlblRCaGUg==", "requires": { - "@opentelemetry/core": "1.25.0", - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0", + "forwarded-parse": "2.1.2", "semver": "^7.5.2" } }, "@opentelemetry/instrumentation-ioredis": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.42.0.tgz", - "integrity": "sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.44.0.tgz", + "integrity": "sha512-312pE2xc0ihX9haTf9WC4OF9in5EfVO1y5I8Ef9aMQKJNhuSe3IgzQAqGoLfaYajC+ig0IZ9SQKU8mRbFwHU+A==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/semantic-conventions": "^1.27.0" + } + }, + "@opentelemetry/instrumentation-kafkajs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.4.0.tgz", + "integrity": "sha512-I9VwDG314g7SDL4t8kD/7+1ytaDBRbZQjhVaQaVIDR8K+mlsoBhLsWH79yHxhHQKvwCSZwqXF+TiTOhoQVUt7A==", + "requires": { + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-knex": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.38.0.tgz", - "integrity": "sha512-EFef6Ss5ATsf5AxJOLE+pxkfupcWDaejkPH+2q7TNeG1UwsBFobfiWM+iHROZ1Cl/y3mTi60MW70FxsaX2/TjA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.41.0.tgz", + "integrity": "sha512-OhI1SlLv5qnsnm2dOVrian/x3431P75GngSpnR7c4fcVFv7prXGYu29Z6ILRWJf/NJt6fkbySmwdfUUnFnHCTg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-koa": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.42.0.tgz", - "integrity": "sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.44.0.tgz", + "integrity": "sha512-ryPqGIQ4hpMGd85bAGjRMDAy/ic+Qdh1GtFGJo9KaXdzbcvZoF1ZgXVsKTYDxbD1n5C0BoQy6rcWg8Lu68iCJA==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.39.0.tgz", - "integrity": "sha512-eU1Wx1RRTR/2wYXFzH9gcpB8EPmhYlNDIUHzUXjyUE0CAXEJhBLkYNlzdaVCoQDw2neDqS+Woshiia6+emWK9A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.41.0.tgz", + "integrity": "sha512-6OePkk4RYCPVsnS0TroEK6UZzxxxjVWaE6EPdOn2qxGHMtm+Qb80tiBQ6BbmC+f7bjc27O85JY8gxeTybhHZXw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-memcached": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.38.0.tgz", - "integrity": "sha512-tPmyqQEZNyrvg6G+iItdlguQEcGzfE+bJkpQifmBXmWBnoS5oU3UxqtyYuXGL2zI9qQM5yMBHH4nRXWALzy7WA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.40.0.tgz", + "integrity": "sha512-VzJUUH6cVz8yrb25RvvjhxCpwu4vUk28I0m5nnnhebULOo8p9lda5PgQeVde2+jQAd977C/vN714fkbYOmwb+A==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/memcached": "^2.2.6" } }, "@opentelemetry/instrumentation-mongodb": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.46.0.tgz", - "integrity": "sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.48.0.tgz", + "integrity": "sha512-9YWvaGvrrcrydMsYGLu0w+RgmosLMKe3kv/UNlsPy8RLnCkN2z+bhhbjjjuxtUmvEuKZMCoXFluABVuBr1yhjw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/sdk-metrics": "^1.9.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-mongoose": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.40.0.tgz", - "integrity": "sha512-niRi5ZUnkgzRhIGMOozTyoZIvJKNJyhijQI4nF4iFSb+FUx2v5fngfR+8XLmdQAO7xmsD8E5vEGdDVYVtKbZew==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.43.0.tgz", + "integrity": "sha512-y1mWuL/zb6IKi199HkROgmStxF/ybEsnKYgx+/lpLATd57oZHOqrXP9tLmp9qRVI5c6P5XEWfe7ZCvrj07iDMQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-mysql": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.40.0.tgz", - "integrity": "sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.42.0.tgz", + "integrity": "sha512-1GN2EBGVSZABGQ25MSz3faeBW/DwhzmE10aNW1/A2mvQAxF1CvpMk17YmNUzwapVt29iKsiU3SXQG7vjh/019A==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/mysql": "2.15.22" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" } }, "@opentelemetry/instrumentation-mysql2": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.40.0.tgz", - "integrity": "sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.42.0.tgz", + "integrity": "sha512-CQqOjCbHwEnaC+Bd6Sms+82iJkSbPpd7jD7Jwif7q8qXo6yrKLVDYDVK+zKbfnmQtu2xHaHj+xiq4tyjb3sMfg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1" } }, "@opentelemetry/instrumentation-nestjs-core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.39.0.tgz", - "integrity": "sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.41.0.tgz", + "integrity": "sha512-XCqtghFktpcJ2BOaJtFfqtTMsHffJADxfYhJl28WT6ygCChS2uZVxMKKLsy+i9VtPaw/i1IumPICL6mbhwq+Vw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-net": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.38.0.tgz", - "integrity": "sha512-stjow1PijcmUquSmRD/fSihm/H61DbjPlJuJhWUe7P22LFPjFhsrSeiB5vGj3vn+QGceNAs+kioUTzMGPbNxtg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.40.0.tgz", + "integrity": "sha512-abErnVRxTmtiF7EvBISW81Se2nj/j3Xtpfy//9++dgvDOXwbcD1Xz1via6ZHOm/VamboGhqPlYiO7ABzluPLwg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-pg": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.43.0.tgz", - "integrity": "sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.47.0.tgz", + "integrity": "sha512-aKu5PCeUv3S8s1wq60JZ2o3DWV2wqvO7WAktjmkx5wXd2+tZRfyDCKFHbP90QuDG1HDzjJ138Ob4d4rJdPETCQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.4" + "@types/pg-pool": "2.0.6" }, "dependencies": { "@types/pg": { @@ -18747,182 +17124,129 @@ } }, "@opentelemetry/instrumentation-pino": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.41.0.tgz", - "integrity": "sha512-Kpv0fJRk/8iMzMk5Ue5BsUJfHkBJ2wQoIi/qduU1a1Wjx9GLj6J2G17PHjPK5mnZjPNzkFOXFADZMfgDioliQw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.43.0.tgz", + "integrity": "sha512-jlOOgbODWRRNknWXY1VLgmqgG0SO4kLgU3XnejjO/3De4OisroAsMGk+1cRB5AQ6WZ8WLAMkMyTShaOe6j2Asw==", "requires": { - "@opentelemetry/api-logs": "^0.52.0", + "@opentelemetry/api-logs": "^0.54.0", "@opentelemetry/core": "^1.25.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-redis": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.41.0.tgz", - "integrity": "sha512-RJ1pwI3btykp67ts+5qZbaFSAAzacucwBet5/5EsKYtWBpHbWwV/qbGN/kIBzXg5WEZBhXLrR/RUq0EpEUpL3A==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.43.0.tgz", + "integrity": "sha512-dufe08W3sCOjutbTJmV6tg2Y3+7IBe59oQrnIW2RCgjRhsW0Jjaenezt490eawO0MdXjUfFyrIUg8WetKhE4xA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-redis-4": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.41.0.tgz", - "integrity": "sha512-H7IfGTqW2reLXqput4yzAe8YpDC0fmVNal95GHMLOrS89W+qWUKIqxolSh63hJyfmwPSFwXASzj7wpSk8Az+Dg==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.43.0.tgz", + "integrity": "sha512-6B2+CFRY9xRnkeZrSvlTyY2yB/zAgxjbXS5EwXhE3ZAKR1hWWoUzaTADIKT5xe9/VbDW42U3UoOPCcaCmeAXww==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.54.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-restify": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.40.0.tgz", - "integrity": "sha512-sm/rH/GysY/KOEvZqYBZSLYFeXlBkHCgqPDgWc07tz+bHCN6mPs9P3otGOSTe7o3KAIM8Nc6ncCO59vL+jb2cA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.42.0.tgz", + "integrity": "sha512-ApDD9HNy6de6xrHmISEfkQHwwX1f1JrBj0ADnlk6tVdJ0j/vNmsZNLwaU2IA2K3mHqbp2YLarLgxAZp6rjcfWg==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-router": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.39.0.tgz", - "integrity": "sha512-LaXnVmD69WPC4hNeLzKexCCS19hRLrUw3xicneAMkzJSzNJvPyk7G6I7lz7VjQh1cooObPBt9gNyd3hhTCUrag==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.41.0.tgz", + "integrity": "sha512-IbvzgaoylMqStOOtwucEvSu5CDbfQN+H1ZZ2p6c9Kmvzptqh6G441GFy0FFVVqxOAHNhQm2w6n0Ag8trdBjCfw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-socket.io": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.41.0.tgz", - "integrity": "sha512-7fzDe9/FpO6NFizC/wnzXXX7bF9oRchsD//wFqy5g5hVEgXZCQ70IhxjrKdBvgjyIejR9T9zTvfQ6PfVKfkCAw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.43.0.tgz", + "integrity": "sha512-HAQoIZ6N/ey1L4jF69gmqo7RyeSv5rc4sZZAd1v6SVaB8ZolTEyWEzGlu1NRZZTnqfWNxDkX6J1/omWpDd9k0w==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-tedious": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.12.0.tgz", - "integrity": "sha512-53xx7WQmpBPfxtVxOKRzzZxOjv9JzSdoy1aIvCtPM5/O407aYcdvj8wXxCQEiEfctFEovEHG4QgmdHz9BKidSQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.15.0.tgz", + "integrity": "sha512-Kb7yo8Zsq2TUwBbmwYgTAMPK0VbhoS8ikJ6Bup9KrDtCx2JC01nCb+M0VJWXt7tl0+5jARUbKWh5jRSoImxdCw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/tedious": "^4.0.10" + "@opentelemetry/instrumentation": "^0.54.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/tedious": "^4.0.14" } }, "@opentelemetry/instrumentation-undici": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.4.0.tgz", - "integrity": "sha512-UdMQBpz11SqtWlmDnk5SoqF5QDom4VmW8SVDt9Q2xuMWVh8lc0kVROfoo2pl7zU6H6gFR8eudb3eFXIdrFn0ew==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.7.0.tgz", + "integrity": "sha512-1AAqbVt1QOLgnc9DEkHS2R/0FIPI74ud5qgitwP9sVYzRg6e66bPSoAIARCyuANJrWCUrfgI69vLTfRxhBM+3A==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/instrumentation-winston": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.39.0.tgz", - "integrity": "sha512-v/1xziLJ9CyB3YDjBSBzbB70Qd0JwWTo36EqWK5m3AR0CzsyMQQmf3ZIZM6sgx7hXMcRQ0pnEYhg6nhrUQPm9A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.41.0.tgz", + "integrity": "sha512-qtqGDx2Plu71s9xaeXut0YgZFG/y68ENG9vvo/SODeEC+4/APiS/htQ5YNJIxxjOuxYowdFYRqV9Kmef2EUzmw==", "requires": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/api-logs": "^0.54.0", + "@opentelemetry/instrumentation": "^0.54.0" } }, "@opentelemetry/otlp-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", - "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.54.0.tgz", + "integrity": "sha512-g+H7+QleVF/9lz4zhaR9Dt4VwApjqG5WWupy5CTMpWJfHB/nLxBbX73GBZDgdiNfh08nO3rNa6AS7fK8OhgF5g==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-transformer": "0.52.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-transformer": "0.54.0" } }, "@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.52.1.tgz", - "integrity": "sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.54.0.tgz", + "integrity": "sha512-Yl2Dw0jlRWisEia9Hv/N8u2JLITCvzA6gAIKEvxpEu6nwHEftD2WhTJMIclkTtfmSW0rLmEEXymwmboG4xDN0Q==", "requires": { "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/otlp-exporter-base": "0.54.0", + "@opentelemetry/otlp-transformer": "0.54.0" } }, "@opentelemetry/otlp-transformer": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.52.1.tgz", - "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.54.0.tgz", + "integrity": "sha512-jRexIASQQzdK4AjfNIBfn94itAq4Q8EXR9d3b/OVbhd3kKQKvMr7GkxYDjbeTbY7hHCOLcLfJ3dpYQYGOe8qOQ==", "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", "protobufjs": "^7.3.0" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } } }, "@opentelemetry/propagation-utils": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.10.tgz", - "integrity": "sha512-hhTW8pFp9PSyosYzzuUL9rdm7HF97w3OCyElufFHyUnYnKkCBbu8ne2LyF/KSdI/xZ81ubxWZs78hX4S7pLq5g==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.12.tgz", + "integrity": "sha512-bgab3q/4dYUutUpQCEaSDa+mLoQJG3vJKeSiGuhM4iZaSpkz8ov0fs1MGil5PfxCo6Hhw3bB3bFYhUtnsfT/Pg==", "requires": {} }, "@opentelemetry/propagator-aws-xray": { @@ -18934,49 +17258,19 @@ } }, "@opentelemetry/propagator-b3": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.25.1.tgz", - "integrity": "sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.27.0.tgz", + "integrity": "sha512-pTsko3gnMioe3FeWcwTQR3omo5C35tYsKKwjgTCTVCgd3EOWL9BZrMfgLBmszrwXABDfUrlAEFN/0W0FfQGynQ==", "requires": { - "@opentelemetry/core": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0" } }, "@opentelemetry/propagator-jaeger": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.25.1.tgz", - "integrity": "sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.27.0.tgz", + "integrity": "sha512-EI1bbK0wn0yIuKlc2Qv2LKBRw6LiUWevrjCF80fn/rlaB+7StAi8Y5s8DBqAYNpY7v1q86+NjU18v7hj2ejU3A==", "requires": { - "@opentelemetry/core": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0" } }, "@opentelemetry/redis-common": { @@ -18985,259 +17279,134 @@ "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==" }, "@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.28.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.28.10.tgz", - "integrity": "sha512-TZv/1Y2QCL6sJ+X9SsPPBXe4786bc/Qsw0hQXFsNTbJzDTGGUmOAlSZ2qPiuqAd4ZheUYfD+QA20IvAjUz9Hhg==", + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.4.tgz", + "integrity": "sha512-U3sWPoBXiEE51jJGhRrW19hLvrRbBbZWTp3Yc7IaRVFODNNzmibOolyi2ow1XN68UgRT4BRuwgwbnM5GbG/E5Q==", "requires": { - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/resource-detector-aws": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.5.2.tgz", - "integrity": "sha512-LNwKy5vJM5fvCDcbXVKwg6Y1pKT4WgZUsddGMnWMEhxJcQVZm2Z9vUkyHdQU7xvJtGwCO2/TkMWHPjU1KQNDJQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.7.0.tgz", + "integrity": "sha512-VxrwUi/9QcVIV+40d/jOKQthfD/E4/ppQ9FsYpDH7qy16cOO5519QOdihCQJYpVNbgDqf6q3hVrCy1f8UuG8YA==", "requires": { "@opentelemetry/core": "^1.0.0", - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/resource-detector-azure": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.2.9.tgz", - "integrity": "sha512-16Z6kyrmszoa7J1uj1kbSAgZuk11K07yEDj6fa3I9XBf8Debi8y4K8ex94kpxbCfEraWagXji3bCWvaq3k4dRg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.2.12.tgz", + "integrity": "sha512-iIarQu6MiCjEEp8dOzmBvCSlRITPFTinFB2oNKAjU6xhx8d7eUcjNOKhBGQTvuCriZrxrEvDaEEY9NfrPQ6uYQ==", "requires": { + "@opentelemetry/core": "^1.25.1", "@opentelemetry/resources": "^1.10.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/resource-detector-container": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.3.11.tgz", - "integrity": "sha512-22ndMDakxX+nuhAYwqsciexV8/w26JozRUV0FN9kJiqSWtA1b5dCVtlp3J6JivG5t8kDN9UF5efatNnVbqRT9Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.5.0.tgz", + "integrity": "sha512-ozp+ggcbl17xFfL91+DFgP8nmfzthNLxVTDOQUVgQgngVsSaBb5/I1Tnt63ZX2GCMdBJTxUBbFsqFvO0CjfGLg==", "requires": { - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/core": "^1.26.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/resource-detector-gcp": { - "version": "0.29.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.10.tgz", - "integrity": "sha512-rm2HKJ9lsdoVvrbmkr9dkOzg3Uk0FksXNxvNBgrCprM1XhMoJwThI5i0h/5sJypISUAJlEeJS6gn6nROj/NpkQ==", + "version": "0.29.13", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.13.tgz", + "integrity": "sha512-vdotx+l3Q+89PeyXMgKEGnZ/CwzwMtuMi/ddgD9/5tKZ08DfDGB2Npz9m2oXPHRCjc4Ro6ifMqFlRyzIvgOjhg==", "requires": { "@opentelemetry/core": "^1.0.0", - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/resources": "^1.10.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "gcp-metadata": "^6.0.0" } }, "@opentelemetry/resources": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.25.1.tgz", - "integrity": "sha512-pkZT+iFYIZsVn6+GzM0kSX+u3MSLCY9md+lIJOoKl/P+gJFfxJte/60Usdp8Ce4rOs8GduUpSPNe1ddGyDT1sQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/sdk-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.52.1.tgz", - "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.54.0.tgz", + "integrity": "sha512-HeWvOPiWhEw6lWvg+lCIi1WhJnIPbI4/OFZgHq9tKfpwF3LX6/kk3+GR8sGUGAEZfbjPElkkngzvd2s03zbD7Q==", "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" } }, "@opentelemetry/sdk-metrics": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", - "integrity": "sha512-9Mb7q5ioFL4E4dDrc4wC/A3NTHDat44v4I3p2pLPSxRvqUbDIQyMVr9uK+EU69+HWhlET1VaSrRzwdckWqY15Q==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.27.0.tgz", + "integrity": "sha512-JzWgzlutoXCydhHWIbLg+r76m+m3ncqvkCcsswXAQ4gqKS+LOHKhq+t6fx1zNytvLuaOUBur7EvWxECc4jPQKg==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "lodash.merge": "^4.6.2" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" } }, "@opentelemetry/sdk-node": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.52.1.tgz", - "integrity": "sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.54.0.tgz", + "integrity": "sha512-F0mdwb4WPFJNypcmkxQnj3sIfh/73zkBgYePXMK8ghsBwYw4+PgM3/85WT6NzNUeOvWtiXacx5CFft2o7rGW3w==", "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/exporter-trace-otlp-grpc": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-proto": "0.52.1", - "@opentelemetry/exporter-zipkin": "1.25.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/instrumentation": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", - "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", - "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - }, - "import-in-the-middle": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.1.tgz", - "integrity": "sha512-yhRwoHtiLGvmSozNOALgjRPFI6uYsds60EoMqqnXyyv+JOIW/BrrLejuTGBt+bq0T5tLzOHrN0T7xYTm4Qt/ng==", - "requires": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - } + "@opentelemetry/api-logs": "0.54.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.54.0", + "@opentelemetry/exporter-logs-otlp-http": "0.54.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.54.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.54.0", + "@opentelemetry/exporter-trace-otlp-http": "0.54.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.54.0", + "@opentelemetry/exporter-zipkin": "1.27.0", + "@opentelemetry/instrumentation": "0.54.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/sdk-logs": "0.54.0", + "@opentelemetry/sdk-metrics": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", + "@opentelemetry/sdk-trace-node": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/sdk-trace-base": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", - "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/sdk-trace-node": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.25.1.tgz", - "integrity": "sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.27.0.tgz", + "integrity": "sha512-dWZp/dVGdUEfRBjBq2BgNuBlFqHCxyyMc8FsN0NX15X07mxSUO0SZRLyK/fdAVrde8nqFI/FEdMH4rgU9fqJfQ==", "requires": { - "@opentelemetry/context-async-hooks": "1.25.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/propagator-b3": "1.25.1", - "@opentelemetry/propagator-jaeger": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", + "@opentelemetry/context-async-hooks": "1.27.0", + "@opentelemetry/core": "1.27.0", + "@opentelemetry/propagator-b3": "1.27.0", + "@opentelemetry/propagator-jaeger": "1.27.0", + "@opentelemetry/sdk-trace-base": "1.27.0", "semver": "^7.5.2" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } } }, "@opentelemetry/semantic-conventions": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.0.tgz", - "integrity": "sha512-M+kkXKRAIAiAP6qYyesfrC5TOmDpDVtsxuGfPcqd9B/iBrac+E14jYwrgm0yZBUIbIP2OnqC3j+UgkXLm1vxUQ==" + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" }, "@opentelemetry/sql-common": { "version": "0.40.1", @@ -19248,9 +17417,9 @@ } }, "@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==" + "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==" }, "@pkgjs/parseargs": { "version": "0.11.0", @@ -19323,420 +17492,131 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@radix-ui/colors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-1.0.1.tgz", - "integrity": "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg==" - }, - "@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" - }, - "@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/react-collapsible": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", - "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - } - }, - "@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "requires": {} - }, - "@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "requires": {} - }, - "@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "requires": {} - }, - "@radix-ui/react-dismissable-layer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", - "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - } - }, - "@radix-ui/react-focus-guards": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", - "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", - "requires": {} - }, - "@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" - } - }, - "@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "requires": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-portal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", - "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "requires": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-presence": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", - "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } - }, - "@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "requires": { - "@radix-ui/react-compose-refs": "1.1.0" - } - }, - "@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - } - }, - "@radix-ui/react-tooltip": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.1.tgz", - "integrity": "sha512-LLE8nzNE4MzPMw3O2zlVlkLFid3y9hMUs7uCbSHyKSo+tCN4yMCf+ZCCcfrYgsOC0TiHBPQ1mtpJ2liY3ZT3SQ==", - "requires": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "requires": {} - }, - "@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "requires": { - "@radix-ui/react-use-callback-ref": "1.1.0" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "requires": {} - }, - "@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "requires": { - "@radix-ui/rect": "1.1.0" - } - }, - "@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "requires": { - "@radix-ui/react-use-layout-effect": "1.1.0" - } - }, - "@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "requires": { - "@radix-ui/react-primitive": "2.0.0" - } - }, - "@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" - }, "@react-email/body": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.9.tgz", - "integrity": "sha512-bSGF6j+MbfQKYnnN+Kf57lGp/J+ci+435OMIv/BKAtfmNzHL+ptRrsINJELiO8QzwnZmQjTGKSMAMMJiQS+xwQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.10.tgz", + "integrity": "sha512-dMJyL9aU25ieatdPtVjCyQ/WHZYHwNc+Hy/XpF8Cc18gu21cUynVEeYQzFSeigDRMeBQ3PGAyjVDPIob7YlGwA==", "requires": {} }, "@react-email/button": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.16.tgz", - "integrity": "sha512-paptUerzDhKHEUmBuT0UecCoqo3N6ZQSyDKC1hFALTwKReGW2xQATisinho9Ybh9ZGw6IZ3n1nGtmX5k2sX70Q==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.17.tgz", + "integrity": "sha512-ioHdsk+BpGS/PqjU6JS7tUrVy9yvbUx92Z+Cem2+MbYp55oEwQ9VHf7u4f5NoM0gdhfKSehBwRdYlHt/frEMcg==", "requires": {} }, "@react-email/code-block": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.6.tgz", - "integrity": "sha512-i+TEeI7AyG1pmtO2Mr+TblV08zQnOtTlYB/v45kFMlDWWKTkvIV33oLRqLYOFhCIvoO5fDZA9T+4m6PvhmcNwQ==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.9.tgz", + "integrity": "sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==", "requires": { "prismjs": "1.29.0" } }, "@react-email/code-inline": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.3.tgz", - "integrity": "sha512-SY5Nn4KhjcqqEBHvUwFlOLNmUT78elIGR+Y14eg02LrVKQJ38mFCfXNGDLk4wbP/2dnidkLYq9+60nf7mFMhnQ==", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.4.tgz", + "integrity": "sha512-zj3oMQiiUCZbddSNt3k0zNfIBFK0ZNDIzzDyBaJKy6ZASTtWfB+1WFX0cpTX8q0gUiYK+A94rk5Qp68L6YXjXQ==", "requires": {} }, "@react-email/column": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.11.tgz", - "integrity": "sha512-KvrPuQFn0hlItRRL3vmRuOJgKG+8I0oO9HM5ReLMi5Ns313JSEQogCJaXuOEFkOVeuu5YyY6zy/+5Esccc1AxQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.12.tgz", + "integrity": "sha512-Rsl7iSdDaeHZO938xb+0wR5ud0Z3MVfdtPbNKJNojZi2hApwLAQXmDrnn/AcPDM5Lpl331ZljJS8vHTWxxkvKw==", "requires": {} }, "@react-email/components": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.22.tgz", - "integrity": "sha512-GO6F+fS3c3aQ6OnqL8esQ/KqtrPGwz80U6uQ8Nd/ETpgFt7y1PXvSGfr8v12wyLffAagdowc/JjoThfIr0L6aA==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.25.tgz", + "integrity": "sha512-lnfVVrThEcET5NPoeaXvrz9UxtWpGRcut2a07dLbyKgNbP7vj/cXTI5TuHtanCvhCddFpMDnElNRghDOfPzwUg==", "requires": { - "@react-email/body": "0.0.9", - "@react-email/button": "0.0.16", - "@react-email/code-block": "0.0.6", - "@react-email/code-inline": "0.0.3", - "@react-email/column": "0.0.11", - "@react-email/container": "0.0.13", - "@react-email/font": "0.0.7", - "@react-email/head": "0.0.10", - "@react-email/heading": "0.0.13", - "@react-email/hr": "0.0.9", - "@react-email/html": "0.0.9", - "@react-email/img": "0.0.9", - "@react-email/link": "0.0.9", - "@react-email/markdown": "0.0.11", - "@react-email/preview": "0.0.10", - "@react-email/render": "0.0.17", - "@react-email/row": "0.0.9", - "@react-email/section": "0.0.13", - "@react-email/tailwind": "0.0.19", - "@react-email/text": "0.0.9" + "@react-email/body": "0.0.10", + "@react-email/button": "0.0.17", + "@react-email/code-block": "0.0.9", + "@react-email/code-inline": "0.0.4", + "@react-email/column": "0.0.12", + "@react-email/container": "0.0.14", + "@react-email/font": "0.0.8", + "@react-email/head": "0.0.11", + "@react-email/heading": "0.0.14", + "@react-email/hr": "0.0.10", + "@react-email/html": "0.0.10", + "@react-email/img": "0.0.10", + "@react-email/link": "0.0.10", + "@react-email/markdown": "0.0.12", + "@react-email/preview": "0.0.11", + "@react-email/render": "1.0.1", + "@react-email/row": "0.0.10", + "@react-email/section": "0.0.14", + "@react-email/tailwind": "0.1.0", + "@react-email/text": "0.0.10" } }, "@react-email/container": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.13.tgz", - "integrity": "sha512-ftke0N1FZl8MX3XXxXiiOaiJOnrQz7ZXUyqNj81K+BK+DePWIVaSmgK6Bu8fFnsgwdKuBdqjZTEtF4sIkU3FuQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.14.tgz", + "integrity": "sha512-NgoaJJd9tTtsrveL86Ocr/AYLkGyN3prdXKd/zm5fQpfDhy/NXezyT3iF6VlwAOEUIu64ErHpAJd+P6ygR+vjg==", "requires": {} }, "@react-email/font": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.7.tgz", - "integrity": "sha512-R0/mfUV/XcUQIALjZUFT9GP+XGmIP1KPz20h9rpS5e4ji6VkQ3ENWlisxrdK5U+KA9iZQrlan+/6tUoTJ9bFsg==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.8.tgz", + "integrity": "sha512-fSBEqYyVPAyyACBBHcs3wEYzNknpHMuwcSAAKE8fOoDfGqURr/vSxKPdh4tOa9z7G4hlcEfgGrCYEa2iPT22cw==", "requires": {} }, "@react-email/head": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.10.tgz", - "integrity": "sha512-VoH399w0/i3dJFnwH0Ixf9BTuiWhSA/y8PpsCJ7CPw8Mv8WNBqMAAsw0rmrITYI8uPd15LZ2zk2uwRDvqasMRw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.11.tgz", + "integrity": "sha512-skw5FUgyamIMK+LN+fZQ5WIKQYf0dPiRAvsUAUR2eYoZp9oRsfkIpFHr0GWPkKAYjFEj+uJjaxQ/0VzQH7svVg==", "requires": {} }, "@react-email/heading": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.13.tgz", - "integrity": "sha512-MYDzjJwljKHBLueLuyqkaHxu6N4aGOL1ms2NNyJ9WXC9mmBnLs4Y/QEf9SjE4Df3AW4iT9uyfVHuaNUb7uq5QA==", - "requires": { - "@radix-ui/react-slot": "1.1.0" - } + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.14.tgz", + "integrity": "sha512-jZM7IVuZOXa0G110ES8OkxajPTypIKlzlO1K1RIe1auk76ukQRiCg1IRV4HZlWk1GGUbec5hNxsvZa2kU8cb9w==", + "requires": {} }, "@react-email/hr": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.9.tgz", - "integrity": "sha512-Rte+EZL3ptH3rkVU3a7fh8/06mZ6Q679tDaWDjsw3878RQC9afWqUPp5lwgA/1pTouLmJlDs2BjRnV6H84O7iw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.10.tgz", + "integrity": "sha512-3AA4Yjgl3zEid/KVx6uf6TuLJHVZvUc2cG9Wm9ZpWeAX4ODA+8g9HyuC0tfnjbRsVMhMcCGiECuWWXINi+60vA==", "requires": {} }, "@react-email/html": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.9.tgz", - "integrity": "sha512-NB74xwWaOJZxhpiy6pzkhHvugBa2vvmUa0KKnSwOEIX+WEQH8wj5UUhRN4F+Pmkiqz3QBTETUJiSsNWWFtrHgA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.10.tgz", + "integrity": "sha512-06uiuSKJBWQJfhCKv4MPupELei4Lepyz9Sth7Yq7Fq29CAeB1ejLgKkGqn1I+FZ72hQxPLdYF4iq4yloKv3JCg==", "requires": {} }, "@react-email/img": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.9.tgz", - "integrity": "sha512-zDlQWmlSANb2dBYhDaKD12Z4xaGD5mEf3peawBYHGxYySzMLwRT2ANGvFqpDNd7iT0C5po+/9EWR8fS1dLy0QQ==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.10.tgz", + "integrity": "sha512-pJ8glJjDNaJ53qoM95pvX9SK05yh0bNQY/oyBKmxlBDdUII6ixuMc3SCwYXPMl+tgkQUyDgwEBpSTrLAnjL3hA==", "requires": {} }, "@react-email/link": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.9.tgz", - "integrity": "sha512-rRqWGPUTGFwwtMCtsdCHNh0ewOsd4UBG/D12UcwJYFKRb0U6hUG/6VJZE3tB1QYZpLIESdvOLL6ztznh+D749g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.10.tgz", + "integrity": "sha512-tva3wvAWSR10lMJa9fVA09yRn7pbEki0ZZpHE6GD1jKbFhmzt38VgLO9B797/prqoDZdAr4rVK7LJFcdPx3GwA==", "requires": {} }, "@react-email/markdown": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.11.tgz", - "integrity": "sha512-KeDTS0bAvvtgavYAIAmxKpRxWUSr1/jufckDzu9g4QsQtth8wYaSR5wCPXuTPmhFgJMIlNSlOiBnVp+oRbDtKA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.12.tgz", + "integrity": "sha512-wsuvj1XAb6O63aizCLNEeqVgKR3oFjAwt9vjfg2y2oh4G1dZeo8zonZM2x1fmkEkBZhzwSHraNi70jSXhA3A9w==", "requires": { "md-to-react-email": "5.0.2" } }, "@react-email/preview": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.10.tgz", - "integrity": "sha512-bRrv8teMMBlF7ttLp1zZUejkPUzrwMQXrigdagtEBOqsB8HxvJU2MR6Yyb3XOqBYldaIDOQJ1z61zyD2wRlKAw==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.11.tgz", + "integrity": "sha512-7O/CT4b16YlSGrj18htTPx3Vbhu2suCGv/cSe5c+fuSrIM/nMiBSZ3Js16Vj0XJbAmmmlVmYFZw9L20wXJ+LjQ==", "requires": {} }, "@react-email/render": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz", - "integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.1.tgz", + "integrity": "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==", "requires": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -19744,27 +17624,27 @@ } }, "@react-email/row": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.9.tgz", - "integrity": "sha512-ZDASHVvyKrWBS00o5pSH5khfMf46UtZhrHcSAfPSiC4nj7R8A0bf+3Wmbk8YmsaV+qWXUCUSHWwIAAlMRnJoAA==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.10.tgz", + "integrity": "sha512-jPyEhG3gsLX+Eb9U+A30fh0gK6hXJwF4ghJ+ZtFQtlKAKqHX+eCpWlqB3Xschd/ARJLod8WAswg0FB+JD9d0/A==", "requires": {} }, "@react-email/section": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.13.tgz", - "integrity": "sha512-McsCQ5NQlNWEMEAR3EtCxHgRhxGmLD+jPvj7A3FD7y2X3fXG0hbmUGX12B63rIywSWjJoQi6tojx/8RpzbyeTA==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.14.tgz", + "integrity": "sha512-+fYWLb4tPU1A/+GE5J1+SEMA7/wR3V30lQ+OR9t2kAJqNrARDbMx0bLnYnR1QL5TiFRz0pCF05SQUobk6gHEDQ==", "requires": {} }, "@react-email/tailwind": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.19.tgz", - "integrity": "sha512-bA0w4D7mSNowxWhcO0jBJauFIPf2Ok7QuKlrHwCcxyX35L2pb5D6ZmXYOrD9C6ADQuVz5oEX+oed3zpSLROgPg==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.1.0.tgz", + "integrity": "sha512-qysVUEY+M3SKUvu35XDpzn7yokhqFOT3tPU6Mj/pgc62TL5tQFj6msEbBtwoKs2qO3WZvai0DIHdLhaOxBQSow==", "requires": {} }, "@react-email/text": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.9.tgz", - "integrity": "sha512-UNFPGerER3zywpb1ODOS2VgHP7rgOmiTxMHn75pjvQf/gi3/jN9edEQLYvRgPv/mNn4IpJFkOrlP8jcammLeew==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.10.tgz", + "integrity": "sha512-wNAnxeEAiFs6N+SxS0y6wTJWfewEzUETuyS2aZmT00xk50VijwyFRuhm4sYSjusMyshevomFwz5jNISCxRsGWw==", "requires": {} }, "@rollup/pluginutils": { @@ -19793,114 +17673,114 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "dev": true, "optional": true }, @@ -19952,92 +17832,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.2.tgz", - "integrity": "sha512-mjIlT0e6ygKR8LZ1TjtNrDVMhnB8qpyYAdwexhuVHY255yDdDQCpuPGi20odwnE82QhFBSIWs4HcENDVO/yiMw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", + "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.2", - "@swc/core-darwin-x64": "1.7.2", - "@swc/core-linux-arm-gnueabihf": "1.7.2", - "@swc/core-linux-arm64-gnu": "1.7.2", - "@swc/core-linux-arm64-musl": "1.7.2", - "@swc/core-linux-x64-gnu": "1.7.2", - "@swc/core-linux-x64-musl": "1.7.2", - "@swc/core-win32-arm64-msvc": "1.7.2", - "@swc/core-win32-ia32-msvc": "1.7.2", - "@swc/core-win32-x64-msvc": "1.7.2", + "@swc/core-darwin-arm64": "1.7.39", + "@swc/core-darwin-x64": "1.7.39", + "@swc/core-linux-arm-gnueabihf": "1.7.39", + "@swc/core-linux-arm64-gnu": "1.7.39", + "@swc/core-linux-arm64-musl": "1.7.39", + "@swc/core-linux-x64-gnu": "1.7.39", + "@swc/core-linux-x64-musl": "1.7.39", + "@swc/core-win32-arm64-msvc": "1.7.39", + "@swc/core-win32-ia32-msvc": "1.7.39", + "@swc/core-win32-x64-msvc": "1.7.39", "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.12" + "@swc/types": "^0.1.13" } }, "@swc/core-darwin-arm64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.2.tgz", - "integrity": "sha512-Zb8KiGaESzOgh5HBnp6Vhs2fRpngHIT81JOfIo0oaGlzAckamnG7UAXC/yK6cQ8q2KXc78utJ/yq/NM2yVKLqw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", + "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.2.tgz", - "integrity": "sha512-qb0HY9GEexpPm46Hb3OY7E6xb4r+eniiThm+0Gcnhf19EZV2ZlsCC8Rdbhmav33x++ZqSDzZ44fxMY2vnN5VDg==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", + "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.2.tgz", - "integrity": "sha512-x2+MOK3RzH3yEkaukKtpDW/udM1x9GoYtXaLNqlq6ovAzZPQ9FDFI0pm1asL4akHUw3s7YTh1aUY7QscstJAHQ==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", + "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.2.tgz", - "integrity": "sha512-4J3HGEDus7a9xnrJUFGyJJgvj4w+BFGiZvs08xbw4Z1ZN4uHJQiJiDsQEAWWciKUxrOndP3SocUq/GhEGiDm0g==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", + "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.2.tgz", - "integrity": "sha512-4FhQmYbj8SCmir4pHRLSn8IIFmRKHTL3eZFtOpm26RLME7rXL7Yt33DpzIeTRoHFIesI5NEfaR38WU5mY7P1pA==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", + "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.2.tgz", - "integrity": "sha512-Loz10Hy6z5mBIAOe6OInOVsYu+PVxyknCB3thtr7QH+uqEz6dcXhU2ERrO2Lf4dsTsFs/Wb80rv8zTSwB8dpsw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", + "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.2.tgz", - "integrity": "sha512-8p8qNWaLcTa+qHX4NSv1KNm8BQ6zPoLXuOBo9DtOEqc+K60IISGKPCAS7TJlCcv0q20JnmxZ/cEWW5Qo4TR4XQ==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", + "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.2.tgz", - "integrity": "sha512-eNWAYOalBlFrhv/IVSQ1dxu7qIGuhxlUJZTYa8jsgLnKt93vAFd2cjLtKZ85k1OibBnq9PkKQyo4NKVr4hBavw==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", + "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.2.tgz", - "integrity": "sha512-BbpaCPCnbQHCzpQ9yDH3qp1Y5Ijd0NSMNk4qqESN2WWx0ojV2uBTjPou5NC2MZxk8fM3iJpJ05enf+IeaXuh6A==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", + "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.2.tgz", - "integrity": "sha512-21mf4Jg9Arx0lUnmRQtYd8IQB4WkY4LHJrvcz3EmKbwCTCXI5rQ6Ifnjk7EmG3Tizv0giHqQBQLu5NXWBz45Mg==", + "version": "1.7.39", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", + "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", "dev": true, "optional": true }, @@ -20047,28 +17927,30 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "@swc/helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", - "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", "requires": { + "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "@swc/types": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", - "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", + "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", + "devOptional": true, "requires": { "@swc/counter": "^0.1.3" } }, "@testcontainers/postgresql": { - "version": "10.10.4", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.10.4.tgz", - "integrity": "sha512-yGRW3IYXAnv91ncOyhf6XVSMbKqfKQzFbFdaSu67agtXwIUYvGE+RFXa/SMZ6oNKHNWgMGKXB9Paj7+md79+VQ==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.2.tgz", + "integrity": "sha512-xd3u/rL8FrOBHFMu1aU+2d4sqPz9ffEb19ITtopT/tyBZWW9qCsgR6wSg0r2BJUd+2hT4UR5nR5cymi+ROkehw==", "dev": true, "requires": { - "testcontainers": "^10.10.4" + "testcontainers": "^10.13.2" } }, "@tsconfig/node10": { @@ -20100,31 +17982,40 @@ "peer": true }, "@turf/boolean-point-in-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", - "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "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==", "requires": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@turf/invariant": "^7.1.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.6.2" } }, "@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==" + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", + "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "requires": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + } }, "@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz", + "integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==", "requires": { - "@turf/helpers": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" } }, "@types/archiver": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.2.tgz", - "integrity": "sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", "dev": true, "requires": { "@types/readdir-glob": "*" @@ -20137,9 +18028,9 @@ "dev": true }, "@types/aws-lambda": { - "version": "8.10.122", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.122.tgz", - "integrity": "sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==" + "version": "8.10.143", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.143.tgz", + "integrity": "sha512-u5vzlcR14ge/4pMTTMDQr3MF0wEe38B2F9o84uC4F43vN5DGTy63npRrB6jQhyt+C0lGv4ZfiRcRkqJoZuPnmg==" }, "@types/bcrypt": { "version": "5.0.2", @@ -20229,24 +18120,19 @@ "version": "8.44.3", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", + "dev": true, + "optional": true, + "peer": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" } }, - "@types/eslint-scope": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", - "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true }, "@types/express": { "version": "4.17.21", @@ -20273,14 +18159,19 @@ } }, "@types/fluent-ffmpeg": { - "version": "2.1.24", - "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz", - "integrity": "sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==", + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.26.tgz", + "integrity": "sha512-0JVF3wdQG+pN0ImwWD0bNgJiKF2OHg/7CDBHw5UIbRTvlnkgGHK6V5doE54ltvhud4o31/dEiHm23CAlxFiUQg==", "dev": true, "requires": { "@types/node": "*" } }, + "@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, "@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", @@ -20306,12 +18197,13 @@ "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", + "integrity": "sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==", "dev": true }, "@types/luxon": { @@ -20349,34 +18241,34 @@ } }, "@types/multer": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", - "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", "dev": true, "requires": { "@types/express": "*" } }, "@types/mysql": { - "version": "2.15.22", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.22.tgz", - "integrity": "sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==", + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", "requires": { "@types/node": "*" } }, "@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "22.8.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", + "integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.8" } }, "@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "requires": { "@types/node": "*" @@ -20389,9 +18281,9 @@ "dev": true }, "@types/pg": { - "version": "8.10.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", - "integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "requires": { "@types/node": "*", "pg-protocol": "*", @@ -20399,15 +18291,15 @@ }, "dependencies": { "pg-types": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", - "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "requires": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", - "postgres-date": "~2.0.1", + "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } @@ -20426,9 +18318,9 @@ } }, "postgres-date": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", - "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==" }, "postgres-interval": { "version": "3.0.0", @@ -20438,28 +18330,33 @@ } }, "@types/pg-pool": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.4.tgz", - "integrity": "sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", "requires": { "@types/pg": "*" } }, "@types/picomatch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.0.tgz", - "integrity": "sha512-iX/Qwk9vU17N/5Q7QrV46wzciloTdCqTRt6z8A7uFFADM2+Sy5oQh9ldZhAiTXH+l0sM/EkXatEhJIs8FUyOBQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, - "@types/prismjs": { - "version": "1.26.3", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", - "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" + "@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "requires": { + "@types/node": "*" + } }, "@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true }, "@types/qs": { "version": "6.9.8", @@ -20474,22 +18371,15 @@ "dev": true }, "@types/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", - "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, - "@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "requires": { - "@types/react": "*" - } - }, "@types/readdir-glob": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.2.tgz", @@ -20499,11 +18389,6 @@ "@types/node": "*" } }, - "@types/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, "@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -20532,9 +18417,9 @@ } }, "@types/shimmer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.5.tgz", - "integrity": "sha512-9Hp0ObzwwO57DpLFF0InUjUm/II8GmKAvzbefxQTihCb7KI6yc9yzf0nLc4mVdby5N4DRCgQM2wCup9KTieeww==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" }, "@types/ssh2": { "version": "0.5.52", @@ -20604,27 +18489,17 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.8.tgz", "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, - "@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "requires": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "@typescript-eslint/eslint-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", - "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/type-utils": "7.17.0", - "@typescript-eslint/utils": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -20632,56 +18507,56 @@ } }, "@typescript-eslint/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", - "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "requires": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" } }, "@typescript-eslint/type-utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", - "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", - "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", - "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "requires": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -20709,51 +18584,80 @@ } }, "@typescript-eslint/utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", - "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" } }, "@typescript-eslint/visitor-keys": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", - "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "requires": { - "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" } }, - "@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, "@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz", + "integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==", "dev": true, "requires": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, + "dependencies": { + "magic-string": { + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + } + } + }, + "@vitest/expect": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", + "dev": true, + "requires": { + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + } + }, + "@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", + "dev": true, + "requires": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, "dependencies": { "magic-string": { "version": "0.30.11", @@ -20766,45 +18670,33 @@ } } }, - "@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", - "dev": true, - "requires": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "tinyrainbow": "^1.2.0" - } - }, "@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", "dev": true, "requires": { "tinyrainbow": "^1.2.0" } }, "@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", "dev": true, "requires": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.3", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", "dev": true, "requires": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "dependencies": { @@ -20820,22 +18712,21 @@ } }, "@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, "requires": { "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, "requires": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } @@ -20844,6 +18735,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, "requires": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -20852,22 +18744,26 @@ "@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "@webassemblyjs/helper-buffer": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true }, "@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "requires": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20877,12 +18773,14 @@ "@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "@webassemblyjs/helper-wasm-section": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20894,6 +18792,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "requires": { "@xtuc/ieee754": "^1.2.0" } @@ -20902,6 +18801,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "requires": { "@xtuc/long": "4.2.2" } @@ -20909,12 +18809,14 @@ "@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "@webassemblyjs/wasm-edit": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20930,6 +18832,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -20942,6 +18845,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-buffer": "1.12.1", @@ -20953,6 +18857,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", @@ -20966,6 +18871,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, "requires": { "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" @@ -20974,12 +18880,14 @@ "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "abbrev": { "version": "1.1.1", @@ -21018,6 +18926,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "requires": {} }, "acorn-walk": { @@ -21226,14 +19135,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, - "aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "requires": { - "tslib": "^2.0.0" - } - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -21250,12 +19151,6 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "dev": true }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, "asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -21281,19 +19176,6 @@ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" }, - "autoprefixer": { - "version": "10.4.14", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", - "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", - "requires": { - "browserslist": "^4.21.5", - "caniuse-lite": "^1.0.30001464", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, "b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -21410,9 +19292,9 @@ } }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "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==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -21422,7 +19304,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -21499,15 +19381,15 @@ "dev": true }, "bullmq": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz", - "integrity": "sha512-URnHgB01rlCP8RTpmW3kFnvv3vdd2aI1OcBMYQwnqODxGiJUlz9MibDVXE83mq7ee1eS1IvD9lMQqGszX6E5Pw==", + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz", + "integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==", "requires": { "cron-parser": "^4.6.0", "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", - "msgpackr": "^1.10.1", + "msgpackr": "^1.6.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", @@ -21589,7 +19471,8 @@ "camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "peer": true }, "caniuse-lite": { "version": "1.0.30001618", @@ -21652,7 +19535,8 @@ "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true }, "ci-info": { "version": "4.0.0", @@ -21661,9 +19545,9 @@ "dev": true }, "cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" + "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==" }, "class-transformer": { "version": "0.5.1", @@ -21770,11 +19654,6 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" }, - "clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==" - }, "cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -21822,9 +19701,9 @@ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==" }, "comment-json": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.3.tgz", - "integrity": "sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", + "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", "dev": true, "requires": { "array-timsort": "^1.0.3", @@ -21928,12 +19807,19 @@ "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" }, "cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "requires": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + } } }, "cookie-signature": { @@ -22037,6 +19923,13 @@ "requires": { "@types/luxon": "~3.4.0", "luxon": "~3.4.0" + }, + "dependencies": { + "luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" + } } }, "cron-parser": { @@ -22060,12 +19953,14 @@ "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "peer": true }, "csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true }, "dayjs": { "version": "1.11.10", @@ -22078,11 +19973,11 @@ "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==" }, "debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "deep-eql": { @@ -22094,7 +19989,8 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "deepmerge": { "version": "4.3.1", @@ -22145,11 +20041,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "diacritics": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", @@ -22158,7 +20049,8 @@ "didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "peer": true }, "diff": { "version": "4.0.2", @@ -22167,15 +20059,6 @@ "optional": true, "peer": true }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, "discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", @@ -22185,7 +20068,8 @@ "dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "peer": true }, "docker-compose": { "version": "0.24.8", @@ -22252,14 +20136,6 @@ } } }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "requires": { - "esutils": "^2.0.2" - } - }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -22298,11 +20174,6 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" }, - "dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==" - }, "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -22358,9 +20229,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -22372,9 +20243,9 @@ } }, "engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "requires": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -22385,19 +20256,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" - } - }, - "engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" + "ws": "~8.17.1" } }, "engine.io-parser": { @@ -22406,9 +20265,10 @@ "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==" }, "enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, "requires": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -22443,37 +20303,38 @@ "es-module-lexer": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", - "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==" + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", + "dev": true }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@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" } }, "escalade": { @@ -22489,57 +20350,63 @@ "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true }, "eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "dependencies": { + "@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -22547,10 +20414,17 @@ "uri-js": "^4.2.2" } }, + "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 + }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "requires": { "is-glob": "^4.0.3" } @@ -22558,7 +20432,8 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -22569,14 +20444,6 @@ "dev": true, "requires": {} }, - "eslint-config-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-config-turbo/-/eslint-config-turbo-1.10.12.tgz", - "integrity": "sha512-z3jfh+D7UGYlzMWGh+Kqz++hf8LOE96q3o5R8X4HTjmxaBWlLAWG+0Ounr38h+JLR2TJno0hU9zfzoPNkR9BdA==", - "requires": { - "eslint-plugin-turbo": "1.10.12" - } - }, "eslint-plugin-prettier": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", @@ -22587,21 +20454,6 @@ "synckit": "^0.9.1" } }, - "eslint-plugin-turbo": { - "version": "1.10.12", - "resolved": "https://registry.npmjs.org/eslint-plugin-turbo/-/eslint-plugin-turbo-1.10.12.tgz", - "integrity": "sha512-uNbdj+ohZaYo4tFJ6dStRXu2FZigwulR1b3URPXe0Q8YaE7thuekKNP+54CHtZPH9Zey9dmDx5btAQl9mfzGOw==", - "requires": { - "dotenv": "16.0.3" - }, - "dependencies": { - "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" - } - } - }, "eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -22624,20 +20476,13 @@ "regjsparser": "^0.10.0", "semver": "^7.6.1", "strip-indent": "^3.0.0" - }, - "dependencies": { - "globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true - } } }, "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.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -22646,16 +20491,26 @@ "eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true }, "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.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, "requires": { - "acorn": "^8.9.0", + "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true + } } }, "esprima": { @@ -22668,6 +20523,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "requires": { "estraverse": "^5.1.0" } @@ -22676,6 +20532,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "requires": { "estraverse": "^5.2.0" } @@ -22683,7 +20540,8 @@ "estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true }, "estree-walker": { "version": "3.0.3", @@ -22697,7 +20555,8 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true }, "etag": { "version": "1.8.1", @@ -22719,103 +20578,63 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, - "execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "dependencies": { - "is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true - }, - "mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true - }, - "onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "requires": { - "mimic-fn": "^4.0.0" - } - } - } - }, "exiftool-vendored": { - "version": "28.2.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.0.tgz", - "integrity": "sha512-s2k92EB8LSeYjXv4agtpANeH8y1CsEThYqMm7AF1jP64PyFb40AoD0RGf69j28G6RqXkT5JGl4Xwk9kOy3IkjQ==", + "version": "28.6.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", + "integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "requires": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0", + "exiftool-vendored.exe": "12.97.0", + "exiftool-vendored.pl": "12.97.0", "he": "^1.2.0", - "luxon": "^3.4.4" + "luxon": "^3.5.0" } }, "exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", + "integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "optional": true }, "exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.97.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", + "integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -22824,9 +20643,9 @@ }, "dependencies": { "cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" }, "debug": { "version": "2.6.9", @@ -22842,9 +20661,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" } } }, @@ -22866,7 +20685,8 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "fast-diff": { "version": "1.3.0", @@ -22894,12 +20714,14 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "fast-safe-stringify": { "version": "2.1.1", @@ -22930,11 +20752,12 @@ } }, "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "requires": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" } }, "file-source": { @@ -22954,12 +20777,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -22986,48 +20809,27 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "requires": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "requires": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "dependencies": { - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "requires": { - "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" - } - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - } + "flatted": "^3.2.9", + "keyv": "^4.5.4" } }, "flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "fluent-ffmpeg": { "version": "2.1.3", @@ -23087,19 +20889,10 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" }, - "fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" - }, - "framer-motion": { - "version": "10.17.4", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.4.tgz", - "integrity": "sha512-CYBSs6cWfzcasAX8aofgKFZootmkQtR4qxbfTOksBLny/lbUfkGbQAFOS3qnl6Uau1N9y8tUpI7mVIrHgkFjLQ==", - "requires": { - "@emotion/is-prop-valid": "^0.8.2", - "tslib": "^2.4.0" - } + "forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==" }, "fresh": { "version": "0.5.2", @@ -23236,12 +21029,12 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, "geo-tz": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.0.2.tgz", - "integrity": "sha512-NjEzJBzaMhO9C7lFZIsWDkVED7aLxcES3iEZOWJ97dhnDUGhEB8vhW7MaWR+2y4aWvtFV/VyuDi8Y0rUHvm4tw==", + "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==", "requires": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", + "@turf/boolean-point-in-polygon": "^7.1.0", + "@turf/helpers": "^7.1.0", "geobuf": "^3.0.2", "pbf": "^3.2.1" } @@ -23261,12 +21054,6 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, - "get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true - }, "get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -23279,11 +21066,6 @@ "hasown": "^2.0.0" } }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, "get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -23296,12 +21078,6 @@ "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", "dev": true }, - "get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true - }, "glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -23353,29 +21129,14 @@ "glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "globals": { - "version": "13.22.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.22.0.tgz", - "integrity": "sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==", - "requires": { - "type-fest": "^0.20.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "dev": true }, "globrex": { "version": "0.1.2", @@ -23399,7 +21160,8 @@ "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true }, "handlebars": { "version": "4.7.8", @@ -23536,16 +21298,10 @@ "debug": "4" } }, - "human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true - }, "i18n-iso-countries": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.3.tgz", - "integrity": "sha512-yxQVzNvxEaspSqNnCbqLvwTZNXXkGydWcSxytJYZYb0KH5pn13fdywuX0vFxmOg57Z8ff416AuKDx6Oqnx+j9w==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.12.0.tgz", + "integrity": "sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==", "requires": { "diacritics": "1.3.0" } @@ -23566,7 +21322,8 @@ "ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==" + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true }, "import-fresh": { "version": "3.3.0", @@ -23578,9 +21335,9 @@ } }, "import-in-the-middle": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.0.tgz", - "integrity": "sha512-/xQjze8szLNnJ5rvHSzn+dcVXqCAU6Plbk4P24U/jwPmg1wy7IIp9OjKIO5tYue8GSPhDpPDiApQjvBUmWwhsQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", "requires": { "acorn": "^8.8.2", "acorn-import-attributes": "^1.9.5", @@ -23591,7 +21348,8 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true }, "indent-string": { "version": "4.0.0", @@ -23640,14 +21398,6 @@ "wrap-ansi": "^6.0.1" } }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, "ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -23727,11 +21477,6 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==" - }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -23818,7 +21563,8 @@ "jiti": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "peer": true }, "joi": { "version": "17.13.3", @@ -23833,9 +21579,9 @@ } }, "jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==" + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" }, "js-beautify": { "version": "1.15.1", @@ -23899,7 +21645,8 @@ "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "json-parse-even-better-errors": { "version": "2.3.1", @@ -23915,7 +21662,8 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "json5": { "version": "2.2.3", @@ -23939,9 +21687,10 @@ } }, "keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "requires": { "json-buffer": "3.0.1" } @@ -23992,6 +21741,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "requires": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -24005,7 +21755,8 @@ "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "peer": true }, "lines-and-columns": { "version": "1.2.4", @@ -24021,12 +21772,14 @@ "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==" + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "requires": { "p-locate": "^5.0.0" } @@ -24054,7 +21807,8 @@ "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true }, "log-symbols": { "version": "4.1.0", @@ -24079,13 +21833,10 @@ } }, "loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", - "dev": true, - "requires": { - "get-func-name": "^2.0.1" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true }, "lru-cache": { "version": "5.1.1", @@ -24096,9 +21847,9 @@ } }, "luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==" }, "magic-string": { "version": "0.30.8", @@ -24170,14 +21921,15 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "merge2": { "version": "1.4.1", @@ -24190,11 +21942,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -24290,18 +22042,10 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, - "mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "requires": { - "obliterator": "^2.0.1" - } - }, "mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.0.tgz", + "integrity": "sha512-3ROPnEMgBOkusBMYQUW2rnT3wZwsgfOKzJDLvx/TZ7FL1WmWvwSwn3j4aDR5fLDGtgcc1WF0Z1y0di7c9L4FKw==", "dev": true }, "module-details-from-path": { @@ -24321,9 +22065,9 @@ "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "msgpackr": { "version": "1.10.1", @@ -24432,7 +22176,8 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "nearley": { "version": "2.20.1", @@ -24465,9 +22210,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nest-commander": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/nest-commander/-/nest-commander-3.14.0.tgz", - "integrity": "sha512-3HEfsEzoKEZ/5/cptkXlL8/31qohPxtMevoFo4j9NMe3q5PgI/0TgTYN/6py9GnFD51jSasEfFGChs1BJ+Enag==", + "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==", "requires": { "@fig/complete-commander": "^3.0.0", "@golevelup/nestjs-discovery": "4.0.1", @@ -24492,9 +22237,9 @@ } }, "nestjs-cls": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.3.0.tgz", - "integrity": "sha512-MVTun6tqCZih8AJXRj8uBuuFyJhQrIA9m9fStiQjbBXUkE3BrlMRvmLzyw8UcneB3xtFFTfwkAh5PYKRulyaOg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-4.4.1.tgz", + "integrity": "sha512-4yhldwm/cJ02lQ8ZAdM8KQ7gMfjAc1z3fo5QAQgXNyN4N6X5So9BCwv+BTLRugDCkELUo3qtzQHnKhGYL/ftPg==", "requires": {} }, "nestjs-otel": { @@ -24508,21 +22253,21 @@ } }, "next": { - "version": "14.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.4.tgz", - "integrity": "sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", "requires": { - "@next/env": "14.1.4", - "@next/swc-darwin-arm64": "14.1.4", - "@next/swc-darwin-x64": "14.1.4", - "@next/swc-linux-arm64-gnu": "14.1.4", - "@next/swc-linux-arm64-musl": "14.1.4", - "@next/swc-linux-x64-gnu": "14.1.4", - "@next/swc-linux-x64-musl": "14.1.4", - "@next/swc-win32-arm64-msvc": "14.1.4", - "@next/swc-win32-ia32-msvc": "14.1.4", - "@next/swc-win32-x64-msvc": "14.1.4", - "@swc/helpers": "0.5.2", + "@next/env": "14.2.3", + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3", + "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -24576,9 +22321,9 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "nodemailer": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", - "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==" + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==" }, "nopt": { "version": "5.0.0", @@ -24613,33 +22358,11 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==" - }, "notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, - "npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "requires": { - "path-key": "^4.0.0" - }, - "dependencies": { - "path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true - } - } - }, "npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -24662,14 +22385,9 @@ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" - }, - "obliterator": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz", - "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "obuf": { "version": "1.1.2", @@ -24711,11 +22429,11 @@ } }, "openid-client": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", - "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", "requires": { - "jose": "^4.15.5", + "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -24745,6 +22463,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "requires": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -24779,6 +22498,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -24787,6 +22507,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "requires": { "p-limit": "^3.0.2" } @@ -24858,7 +22579,8 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true }, "path-is-absolute": { "version": "1.0.1", @@ -24901,9 +22623,9 @@ } }, "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "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==" }, "path-type": { "version": "4.0.0", @@ -24937,14 +22659,14 @@ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" }, "pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "requires": { "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" } @@ -24956,9 +22678,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "pg-int8": { "version": "1.0.1", @@ -24971,15 +22693,15 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "requires": {} }, "pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "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==" }, "pg-types": { "version": "2.2.0", @@ -25002,9 +22724,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "4.0.2", @@ -25014,12 +22736,14 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "peer": true }, "pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==" + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "peer": true }, "pluralize": { "version": "8.0.0", @@ -25027,20 +22751,32 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, + "point-in-polygon-hao": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", + "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" + }, "postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" } }, "postcss-import": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "peer": true, "requires": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -25051,6 +22787,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "peer": true, "requires": { "camelcase-css": "^2.0.1" } @@ -25059,6 +22796,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "peer": true, "requires": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" @@ -25067,7 +22805,8 @@ "lilconfig": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==" + "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "peer": true } } }, @@ -25075,6 +22814,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "peer": true, "requires": { "postcss-selector-parser": "^6.0.11" } @@ -25083,6 +22823,7 @@ "version": "6.0.16", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "peer": true, "requires": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25091,7 +22832,8 @@ "postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "peer": true }, "postgres-array": { "version": "2.0.0", @@ -25117,14 +22859,15 @@ } }, "postgres-range": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", - "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true }, "prettier": { "version": "3.3.3", @@ -25141,28 +22884,12 @@ } }, "prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "requires": {} }, - "prism-react-renderer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.1.0.tgz", - "integrity": "sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==", - "requires": { - "@types/prismjs": "^1.26.0", - "clsx": "^1.2.1" - }, - "dependencies": { - "clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" - } - } - }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -25220,9 +22947,9 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==" }, "protobufjs": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.2.tgz", - "integrity": "sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", "requires": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -25265,14 +22992,15 @@ "punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "queue-microtask": { @@ -25305,6 +23033,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "requires": { "safe-buffer": "^5.1.0" } @@ -25337,56 +23066,31 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" } }, "react-email": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-2.1.6.tgz", - "integrity": "sha512-BtR9VI1CMq4953wfiBmzupKlWcRThaWG2dDgl1vWAllK3tNNmJNerwY4VlmASRDQZE3LpLXU3+lf8N/VAKdbZQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.1.tgz", + "integrity": "sha512-G4Bkx2ULIScy/0Z8nnWywHt0W1iTkaYCdh9rWNuQ3eVZ6B3ttTUDE9uUy3VNQ8dtQbmG0cpt8+XmImw7mMBW6Q==", "requires": { "@babel/core": "7.24.5", "@babel/parser": "7.24.5", - "@radix-ui/colors": "1.0.1", - "@radix-ui/react-collapsible": "1.1.0", - "@radix-ui/react-popover": "1.1.1", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-toggle-group": "1.1.0", - "@radix-ui/react-tooltip": "1.1.1", - "@swc/core": "1.3.101", - "@types/react": "18.2.47", - "@types/react-dom": "^18.2.0", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.14", "chalk": "4.1.2", - "chokidar": "3.5.3", - "clsx": "2.1.0", + "chokidar": "3.6.0", "commander": "11.1.0", "debounce": "2.0.0", "esbuild": "0.19.11", - "eslint-config-prettier": "9.0.0", - "eslint-config-turbo": "1.10.12", - "framer-motion": "10.17.4", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "14.1.4", + "next": "14.2.3", "normalize-path": "3.0.0", "ora": "5.4.1", - "postcss": "8.4.38", - "prism-react-renderer": "2.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "socket.io": "4.7.3", - "socket.io-client": "4.7.3", - "sonner": "1.3.1", - "source-map-js": "1.0.2", - "stacktrace-parser": "0.1.10", - "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", - "typescript": "5.1.6" + "socket.io": "4.7.5" }, "dependencies": { "@esbuild/aix-ppc64": { @@ -25527,100 +23231,6 @@ "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", "optional": true }, - "@swc/core": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.101.tgz", - "integrity": "sha512-w5aQ9qYsd/IYmXADAnkXPGDMTqkQalIi+kfFf/MHRKTpaOL7DHjMXwPp/n8hJ0qNjRvchzmPtOqtPBiER50d8A==", - "requires": { - "@swc/core-darwin-arm64": "1.3.101", - "@swc/core-darwin-x64": "1.3.101", - "@swc/core-linux-arm-gnueabihf": "1.3.101", - "@swc/core-linux-arm64-gnu": "1.3.101", - "@swc/core-linux-arm64-musl": "1.3.101", - "@swc/core-linux-x64-gnu": "1.3.101", - "@swc/core-linux-x64-musl": "1.3.101", - "@swc/core-win32-arm64-msvc": "1.3.101", - "@swc/core-win32-ia32-msvc": "1.3.101", - "@swc/core-win32-x64-msvc": "1.3.101", - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - } - }, - "@swc/core-darwin-arm64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.101.tgz", - "integrity": "sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==", - "optional": true - }, - "@swc/core-darwin-x64": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.101.tgz", - "integrity": "sha512-B085j8XOx73Fg15KsHvzYWG262bRweGr3JooO1aW5ec5pYbz5Ew9VS5JKYS03w2UBSxf2maWdbPz2UFAxg0whw==", - "optional": true - }, - "@swc/core-linux-arm-gnueabihf": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.101.tgz", - "integrity": "sha512-9xLKRb6zSzRGPqdz52Hy5GuB1lSjmLqa0lST6MTFads3apmx4Vgs8Y5NuGhx/h2I8QM4jXdLbpqQlifpzTlSSw==", - "optional": true - }, - "@swc/core-linux-arm64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.101.tgz", - "integrity": "sha512-oE+r1lo7g/vs96Weh2R5l971dt+ZLuhaUX+n3BfDdPxNHfObXgKMjO7E+QS5RbGjv/AwiPCxQmbdCp/xN5ICJA==", - "optional": true - }, - "@swc/core-linux-arm64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.101.tgz", - "integrity": "sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==", - "optional": true - }, - "@swc/core-linux-x64-gnu": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.101.tgz", - "integrity": "sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==", - "optional": true - }, - "@swc/core-linux-x64-musl": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.101.tgz", - "integrity": "sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==", - "optional": true - }, - "@swc/core-win32-arm64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.101.tgz", - "integrity": "sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==", - "optional": true - }, - "@swc/core-win32-ia32-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.101.tgz", - "integrity": "sha512-onO5KvICRVlu2xmr4//V2je9O2XgS1SGKpbX206KmmjcJhXN5EYLSxW9qgg+kgV5mip+sKTHTAu7IkzkAtElYA==", - "optional": true - }, - "@swc/core-win32-x64-msvc": { - "version": "1.3.101", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.101.tgz", - "integrity": "sha512-T3GeJtNQV00YmiVw/88/nxJ/H43CJvFnpvBHCVn17xbahiVUOPOduh3rc9LgAkKiNt/aV8vU3OJR+6PhfMR7UQ==", - "optional": true - }, - "@types/react": { - "version": "18.2.47", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz", - "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==", - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -25629,21 +23239,6 @@ "balanced-match": "^1.0.0" } }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, "commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -25679,12 +23274,6 @@ "@esbuild/win32-x64": "0.19.11" } }, - "eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", - "requires": {} - }, "glob": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", @@ -25704,69 +23293,6 @@ "requires": { "brace-expansion": "^2.0.1" } - }, - "socket.io": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.3.tgz", - "integrity": "sha512-SE+UIQXBQE+GPG2oszWMlsEmWtHVqw/h1VrYJGK5/MC7CH5p58N448HwIrtREcvR4jfdOJAY4ieQfxMr55qbbw==", - "requires": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.5.2", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - } - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "requires": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "dependencies": { - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - } - } - }, - "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==" } } }, @@ -25785,41 +23311,11 @@ } } }, - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "peer": true, "requires": { "pify": "^2.3.0" } @@ -25969,11 +23465,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -26167,27 +23658,27 @@ } }, "rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "requires": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -26235,6 +23726,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, "requires": { "loose-envify": "^1.1.0" } @@ -26243,6 +23735,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, "requires": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -26253,6 +23746,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -26264,12 +23758,14 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "requires": {} }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -26287,9 +23783,9 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -26321,10 +23817,10 @@ } } }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" } } }, @@ -26332,19 +23828,20 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, "requires": { "randombytes": "^2.1.0" } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-blocking": { @@ -26400,32 +23897,32 @@ } }, "sharp": { - "version": "0.33.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.4.tgz", - "integrity": "sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==", + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "requires": { - "@img/sharp-darwin-arm64": "0.33.4", - "@img/sharp-darwin-x64": "0.33.4", - "@img/sharp-libvips-darwin-arm64": "1.0.2", - "@img/sharp-libvips-darwin-x64": "1.0.2", - "@img/sharp-libvips-linux-arm": "1.0.2", - "@img/sharp-libvips-linux-arm64": "1.0.2", - "@img/sharp-libvips-linux-s390x": "1.0.2", - "@img/sharp-libvips-linux-x64": "1.0.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.2", - "@img/sharp-libvips-linuxmusl-x64": "1.0.2", - "@img/sharp-linux-arm": "0.33.4", - "@img/sharp-linux-arm64": "0.33.4", - "@img/sharp-linux-s390x": "0.33.4", - "@img/sharp-linux-x64": "0.33.4", - "@img/sharp-linuxmusl-arm64": "0.33.4", - "@img/sharp-linuxmusl-x64": "0.33.4", - "@img/sharp-wasm32": "0.33.4", - "@img/sharp-win32-ia32": "0.33.4", - "@img/sharp-win32-x64": "0.33.4", + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5", "color": "^4.2.3", "detect-libc": "^2.0.3", - "semver": "^7.6.0" + "semver": "^7.6.3" } }, "shebang-command": { @@ -26447,13 +23944,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "siginfo": { @@ -26483,21 +23981,15 @@ } }, "sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", "requires": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, "slice-source": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/slice-source/-/slice-source-0.4.1.tgz", @@ -26524,25 +24016,6 @@ "requires": { "debug": "~4.3.4", "ws": "~8.17.1" - }, - "dependencies": { - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - } - } - }, - "socket.io-client": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.3.tgz", - "integrity": "sha512-nU+ywttCyBitXIl9Xe0RSEfek4LneYkJxCeNnKCuhwoH4jGXO1ipIUw/VA/+Vvv2G1MTym11fzFC0SxkrcfXDw==", - "requires": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", - "socket.io-parser": "~4.2.4" } }, "socket.io-parser": { @@ -26554,12 +24027,6 @@ "debug": "~4.3.1" } }, - "sonner": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.3.1.tgz", - "integrity": "sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==", - "requires": {} - }, "source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -26567,14 +24034,15 @@ "dev": true }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, "source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -26583,7 +24051,8 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true } } }, @@ -26631,9 +24100,9 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, "sql-formatter": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.3.2.tgz", - "integrity": "sha512-pNxSMf5DtwhpZ8gUcOGCGZIWtCcyAUx9oLgAtlO4ag7DvlfnETL0BGqXaISc84pNrXvTWmt8Wal1FWKxdTsL3Q==", + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.5.tgz", + "integrity": "sha512-dxYn0OzEmB19/9Y+yh8bqD8kJx2S/4pOTM4QLKxQDh7K6lp1Sx9MhmiF9RUJHSVjfV72KihW5R1h6Kecy6O5qA==", "dev": true, "requires": { "argparse": "^2.0.1", @@ -26669,21 +24138,6 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, - "stacktrace-parser": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz", - "integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==", - "requires": { - "type-fest": "^0.7.1" - }, - "dependencies": { - "type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==" - } - } - }, "standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -26765,12 +24219,6 @@ "ansi-regex": "^5.0.1" } }, - "strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true - }, "strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -26783,7 +24231,8 @@ "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true }, "styled-jsx": { "version": "5.1.1", @@ -26797,6 +24246,7 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "peer": true, "requires": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -26846,14 +24296,6 @@ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.22.0.tgz", "integrity": "sha512-oAP80ymt8ssrAzjX8k3frbL7ys6AotqC35oikG6/SG15wBw+tG9nCk4oPaXIhEaAOAZ8XngxUv3ORq2IuR3r4Q==" }, - "tailwind-merge": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.0.tgz", - "integrity": "sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==", - "requires": { - "@babel/runtime": "^7.23.5" - } - }, "tailwindcss": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", @@ -26925,12 +24367,13 @@ "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -26983,6 +24426,7 @@ "version": "5.27.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -26993,7 +24437,8 @@ "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true } } }, @@ -27001,6 +24446,7 @@ "version": "5.3.10", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, "requires": { "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", @@ -27013,6 +24459,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "requires": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -27023,6 +24470,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -27061,9 +24509,9 @@ } }, "testcontainers": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.11.0.tgz", - "integrity": "sha512-TYgpR+MjZSuX7kSUxTa0f/CsN6eErbMFrAFumW08IvOnU8b+EoRzpzEu7mF0d29M1ItnHfHPUP44HYiE4yP3Zg==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.2.tgz", + "integrity": "sha512-LfEll+AG/1Ks3n4+IA5lpyBHLiYh/hSfI4+ERa6urwfQscbDU+M2iW1qPQrHQi+xJXQRYy4whyK1IEHdmxWa3Q==", "dev": true, "requires": { "@balena/dockerignore": "^1.0.2", @@ -27107,7 +24555,8 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "thenify": { "version": "3.3.1", @@ -27136,15 +24585,21 @@ "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==" }, "tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true }, "tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", "dev": true }, "tinyrainbow": { @@ -27154,9 +24609,9 @@ "dev": true }, "tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true }, "tmp": { @@ -27219,7 +24674,8 @@ "ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "peer": true }, "ts-node": { "version": "10.9.2", @@ -27281,9 +24737,9 @@ } }, "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "tweetnacl": { "version": "0.14.5", @@ -27295,15 +24751,11 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "requires": { "prelude-ls": "^1.2.1" } }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" - }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -27391,15 +24843,15 @@ } }, "typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true }, "ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==" }, "uglify-js": { "version": "3.17.4", @@ -27430,9 +24882,9 @@ } }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "universalify": { "version": "2.0.0", @@ -27481,27 +24933,11 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "requires": { "punycode": "^2.1.0" } }, - "use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, "utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", @@ -27558,9 +24994,9 @@ } }, "validator": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", - "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==" }, "vary": { "version": "1.1.2", @@ -27568,34 +25004,33 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "requires": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" } }, "vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, "requires": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" } }, "vite-tsconfig-paths": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.3.2.tgz", - "integrity": "sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz", + "integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==", "dev": true, "requires": { "debug": "^4.1.1", @@ -27604,36 +25039,36 @@ } }, "vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", "dev": true, "requires": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.3", "why-is-node-running": "^2.3.0" }, "dependencies": { "magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", "dev": true, "requires": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -27645,6 +25080,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dev": true, "requires": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -27664,11 +25100,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, "requires": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -27677,7 +25113,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -27698,6 +25134,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "requires": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -27706,7 +25143,8 @@ "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true } } }, @@ -27719,7 +25157,8 @@ "webpack-sources": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true }, "webpack-virtual-modules": { "version": "0.6.1", @@ -27793,16 +25232,11 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, - "xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -27859,7 +25293,8 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true }, "zip-stream": { "version": "6.0.1", diff --git a/server/package.json b/server/package.json index d273d47ab8..8d3515b8da 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.111.0", + "version": "1.119.1", "description": "", "author": "", "private": true, @@ -18,17 +18,16 @@ "check": "tsc --noEmit", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", - "healthcheck": "node ./dist/utils/healthcheck.js", "test": "vitest", - "test:watch": "vitest --watch", "test:cov": "vitest --coverage", + "test:medium": "vitest --config vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", - "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js", - "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", - "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", - "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", + "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.js", + "typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js", + "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", + "typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", @@ -37,7 +36,6 @@ "dependencies": { "@nestjs/bullmq": "^10.0.1", "@nestjs/common": "^10.2.2", - "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.2.2", "@nestjs/event-emitter": "^2.0.4", "@nestjs/platform-express": "^10.2.2", @@ -46,11 +44,11 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.48.0", + "@opentelemetry/auto-instrumentations-node": "^0.52.0", "@opentelemetry/context-async-hooks": "^1.24.0", - "@opentelemetry/exporter-prometheus": "^0.52.0", - "@opentelemetry/sdk-node": "^0.52.0", - "@react-email/components": "^0.0.22", + "@opentelemetry/exporter-prometheus": "^0.54.0", + "@opentelemetry/sdk-node": "^0.54.0", + "@react-email/components": "^0.0.25", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -60,7 +58,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "^28.1.0", + "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -71,27 +69,30 @@ "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", - "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", - "picomatch": "^4.0.0", - "react-email": "^2.1.2", + "picomatch": "^4.0.2", + "react": "^18.3.1", + "react-email": "^3.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "semver": "^7.6.2", "sharp": "^0.33.0", - "sirv": "^2.0.4", + "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": "^1.0.35", + "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", @@ -107,20 +108,24 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.14.12", + "@types/node": "^22.8.1", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", + "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", - "eslint": "^8.56.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", + "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", @@ -130,10 +135,10 @@ "typescript": "^5.3.3", "unplugin-swc": "^1.4.5", "utimes": "^5.2.1", - "vite-tsconfig-paths": "^4.3.2", + "vite-tsconfig-paths": "^5.0.0", "vitest": "^2.0.5" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 541f7dc659..8ed9d5f6ed 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,68 +1,96 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClsModule } from 'nestjs-cls'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; -import { bullConfig, bullQueues, clsConfig, immichAppConfig } from 'src/config'; import { controllers } from 'src/controllers'; -import { databaseConfig } from 'src/database.config'; import { entities } from 'src/entities'; +import { ImmichWorker } from 'src/enum'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; -import { HttpExceptionFilter } from 'src/middleware/http-exception.filter'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { repositories } from 'src/repositories'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { teardownTelemetry } from 'src/repositories/telemetry.repository'; import { services } from 'src/services'; -import { setupEventHandlers } from 'src/utils/events'; -import { otelConfig } from 'src/utils/instrumentation'; +import { DatabaseService } from 'src/services/database.service'; const common = [...services, ...repositories]; const middleware = [ FileUploadInterceptor, - { provide: APP_FILTER, useClass: HttpExceptionFilter }, + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_GUARD, useClass: AuthGuard }, ]; +const configRepository = new ConfigRepository(); +const { bull, cls, database, otel } = configRepository.getEnv(); + const imports = [ - BullModule.forRoot(bullConfig), - BullModule.registerQueue(...bullQueues), - ClsModule.forRoot(clsConfig), - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - OpenTelemetryModule.forRoot(otelConfig), - TypeOrmModule.forRoot(databaseConfig), + BullModule.forRoot(bull.config), + BullModule.registerQueue(...bull.queues), + ClsModule.forRoot(cls.config), + OpenTelemetryModule.forRoot(otel), + TypeOrmModule.forRootAsync({ + inject: [ModuleRef], + useFactory: (moduleRef: ModuleRef) => { + return { + ...database.config, + poolErrorHandler: (error) => { + moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); + }, + }; + }, + }), TypeOrmModule.forFeature(entities), ]; +abstract class BaseModule implements OnModuleInit, OnModuleDestroy { + private get worker() { + return this.getWorker(); + } + + constructor( + @Inject(ILoggerRepository) logger: ILoggerRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository, + ) { + logger.setAppName(this.worker); + } + + abstract getWorker(): ImmichWorker; + + async onModuleInit() { + this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) }); + this.eventRepository.setup({ services }); + await this.eventRepository.emit('app.bootstrap', this.worker); + } + + async onModuleDestroy() { + await this.eventRepository.emit('app.shutdown', this.worker); + await teardownTelemetry(); + } +} + @Module({ imports: [...imports, ScheduleModule.forRoot()], controllers: [...controllers], providers: [...common, ...middleware], }) -export class ApiModule implements OnModuleInit, OnModuleDestroy { - constructor( - private moduleRef: ModuleRef, - @Inject(IEventRepository) private eventRepository: IEventRepository, - ) {} - - async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'api'); - } - - async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); +export class ApiModule extends BaseModule { + getWorker() { + return ImmichWorker.API; } } @@ -70,19 +98,9 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy { imports: [...imports], providers: [...common, SchedulerRegistry], }) -export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { - constructor( - private moduleRef: ModuleRef, - @Inject(IEventRepository) private eventRepository: IEventRepository, - ) {} - - async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('onBootstrapEvent', 'microservices'); - } - - async onModuleDestroy() { - await this.eventRepository.emit('onShutdownEvent'); +export class MicroservicesModule extends BaseModule { + getWorker() { + return ImmichWorker.MICROSERVICES; } } @@ -91,16 +109,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/bin/database.ts b/server/src/bin/database.ts new file mode 100644 index 0000000000..c861902b4e --- /dev/null +++ b/server/src/bin/database.ts @@ -0,0 +1,11 @@ +import { ConfigRepository } from 'src/repositories/config.repository'; +import { DataSource } from 'typeorm'; + +const { database } = new ConfigRepository().getEnv(); + +/** + * @deprecated - DO NOT USE THIS + * + * this export is ONLY to be used for TypeORM commands in package.json#scripts + */ +export const dataSource = new DataSource({ ...database.config, host: 'localhost' }); diff --git a/server/src/utils/healthcheck.ts b/server/src/bin/healthcheck.ts similarity index 67% rename from server/src/utils/healthcheck.ts rename to server/src/bin/healthcheck.ts index 763fce81b4..6de58c2002 100644 --- a/server/src/utils/healthcheck.ts +++ b/server/src/bin/healthcheck.ts @@ -1,15 +1,17 @@ #!/usr/bin/env node -const port = Number(process.env.IMMICH_PORT) || 3001; -const controller = new AbortController(); +import { ImmichWorker } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; const main = async () => { - if (!process.env.IMMICH_WORKERS_INCLUDE?.includes('api')) { + const { workers, port } = new ConfigRepository().getEnv(); + if (!workers.includes(ImmichWorker.API)) { process.exit(); } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2000); try { - const response = await fetch(`http://localhost:${port}/api/server-info/ping`, { + const response = await fetch(`http://localhost:${port}/api/server/ping`, { signal: controller.signal, }); diff --git a/server/src/bin/sync-open-api.ts b/server/src/bin/sync-open-api.ts index 70e2bb8c35..d5316b34cf 100644 --- a/server/src/bin/sync-open-api.ts +++ b/server/src/bin/sync-open-api.ts @@ -7,7 +7,7 @@ import { useSwagger } from 'src/utils/misc'; const sync = async () => { const app = await NestFactory.create(ApiModule, { preview: true }); - useSwagger(app, true); + useSwagger(app, { write: true }); await app.close(); }; diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553..98f26d879a 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -9,14 +8,13 @@ import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { format } from 'sql-formatter'; -import { databaseConfig } from 'src/database.config'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { entities } from 'src/entities'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { repositories } from 'src/repositories'; import { AccessRepository } from 'src/repositories/access.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { AuthService } from 'src/services/auth.service'; -import { otelConfig } from 'src/utils/instrumentation'; import { Logger } from 'typeorm'; export class SqlLogger implements Logger { @@ -75,18 +73,19 @@ class SqlGenerator { await rm(this.options.targetDir, { force: true, recursive: true }); await mkdir(this.options.targetDir); + const { database, otel } = new ConfigRepository().getEnv(); + const moduleFixture = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot({ - ...databaseConfig, + ...database.config, host: 'localhost', entities, logging: ['query'], logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), - OpenTelemetryModule.forRoot(otelConfig), + OpenTelemetryModule.forRoot(otel), ], providers: [...repositories, AuthService, SchedulerRegistry], }).compile(); diff --git a/server/src/config.ts b/server/src/config.ts index 96ce63cf45..7a7a7b71ac 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,90 +1,27 @@ -import { RegisterQueueOptions } from '@nestjs/bullmq'; -import { ConfigModuleOptions } from '@nestjs/config'; import { CronExpression } from '@nestjs/schedule'; -import { QueueOptions } from 'bullmq'; -import { Request, Response } from 'express'; -import { RedisOptions } from 'ioredis'; -import Joi, { Root } from 'joi'; -import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; -import { ImmichHeader } from 'src/dtos/auth.dto'; +import { + AudioCodec, + Colorspace, + CQMode, + ImageFormat, + LogLevel, + ToneMapping, + TranscodeHWAccel, + TranscodePolicy, + VideoCodec, + VideoContainer, +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; - -export enum TranscodePolicy { - ALL = 'all', - OPTIMAL = 'optimal', - BITRATE = 'bitrate', - REQUIRED = 'required', - DISABLED = 'disabled', -} - -export enum TranscodeTarget { - NONE, - AUDIO, - VIDEO, - ALL, -} - -export enum VideoCodec { - H264 = 'h264', - HEVC = 'hevc', - VP9 = 'vp9', - AV1 = 'av1', -} - -export enum AudioCodec { - MP3 = 'mp3', - AAC = 'aac', - LIBOPUS = 'libopus', -} - -export enum VideoContainer { - MOV = 'mov', - MP4 = 'mp4', - OGG = 'ogg', - WEBM = 'webm', -} - -export enum TranscodeHWAccel { - NVENC = 'nvenc', - QSV = 'qsv', - VAAPI = 'vaapi', - RKMPP = 'rkmpp', - DISABLED = 'disabled', -} - -export enum ToneMapping { - HABLE = 'hable', - MOBIUS = 'mobius', - REINHARD = 'reinhard', - DISABLED = 'disabled', -} - -export enum CQMode { - AUTO = 'auto', - CQP = 'cqp', - ICQ = 'icq', -} - -export enum Colorspace { - SRGB = 'srgb', - P3 = 'p3', -} - -export enum ImageFormat { - JPEG = 'jpeg', - WEBP = 'webp', -} - -export enum LogLevel { - VERBOSE = 'verbose', - DEBUG = 'debug', - LOG = 'log', - WARN = 'warn', - ERROR = 'error', - FATAL = 'fatal', -} +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { + backup: { + database: { + enabled: boolean; + cronExpression: string; + keepLastAmount: number; + }; + }; ffmpeg: { crf: number; threads: number; @@ -141,6 +78,11 @@ export interface SystemConfig { reverseGeocoding: { enabled: boolean; }; + metadata: { + faces: { + import: boolean; + }; + }; oauth: { autoLaunch: boolean; autoRegister: boolean; @@ -167,11 +109,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -218,6 +157,13 @@ export interface SystemConfig { } export const defaults = Object.freeze({ + backup: { + database: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_2AM, + keepLastAmount: 14, + }, + }, ffmpeg: { crf: 23, threads: 0, @@ -225,7 +171,7 @@ export const defaults = Object.freeze({ targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], targetAudioCodec: AudioCodec.AAC, - acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS], + acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS, AudioCodec.PCMS16LE], acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], targetResolution: '720', maxBitrate: '0', @@ -280,12 +226,17 @@ export const defaults = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, }, + metadata: { + faces: { + import: false, + }, + }, oauth: { autoLaunch: false, autoRegister: true, @@ -312,11 +263,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, @@ -361,134 +317,3 @@ export const defaults = Object.freeze({ deleteDelay: 7, }, }); - -const WHEN_DB_URL_SET = Joi.when('DB_URL', { - is: Joi.exist(), - then: Joi.string().optional(), - otherwise: Joi.string().required(), -}); - -export const immichAppConfig: ConfigModuleOptions = { - envFilePath: '.env', - isGlobal: true, - validationSchema: Joi.object({ - IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'), - IMMICH_LOG_LEVEL: Joi.string() - .optional() - .valid(...Object.values(LogLevel)), - - DB_USERNAME: WHEN_DB_URL_SET, - DB_PASSWORD: WHEN_DB_URL_SET, - DB_DATABASE_NAME: WHEN_DB_URL_SET, - DB_URL: Joi.string().optional(), - DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'), - DB_SKIP_MIGRATIONS: Joi.boolean().optional().default(false), - - IMMICH_PORT: Joi.number().optional(), - IMMICH_API_METRICS_PORT: Joi.number().optional(), - IMMICH_MICROSERVICES_METRICS_PORT: Joi.number().optional(), - - IMMICH_TRUSTED_PROXIES: Joi.extend((joi: Root) => ({ - type: 'stringArray', - base: joi.array(), - coerce: (value) => (value.split ? value.split(',') : value), - })) - .stringArray() - .single() - .items( - Joi.string().ip({ - version: ['ipv4', 'ipv6'], - cidr: 'optional', - }), - ), - - IMMICH_METRICS: Joi.boolean().optional().default(false), - IMMICH_HOST_METRICS: Joi.boolean().optional().default(false), - IMMICH_API_METRICS: Joi.boolean().optional().default(false), - IMMICH_IO_METRICS: Joi.boolean().optional().default(false), - }), -}; - -export function parseRedisConfig(): RedisOptions { - const redisUrl = process.env.REDIS_URL; - if (redisUrl && redisUrl.startsWith('ioredis://')) { - try { - const decodedString = Buffer.from(redisUrl.slice(10), 'base64').toString(); - return JSON.parse(decodedString); - } catch (error) { - throw new Error(`Failed to decode redis options: ${error}`); - } - } - return { - host: process.env.REDIS_HOSTNAME || 'redis', - port: Number.parseInt(process.env.REDIS_PORT || '6379'), - db: Number.parseInt(process.env.REDIS_DBINDEX || '0'), - username: process.env.REDIS_USERNAME || undefined, - password: process.env.REDIS_PASSWORD || undefined, - path: process.env.REDIS_SOCKET || undefined, - }; -} - -export const bullConfig: QueueOptions = { - prefix: 'immich_bull', - connection: parseRedisConfig(), - defaultJobOptions: { - attempts: 3, - removeOnComplete: true, - removeOnFail: false, - }, -}; - -export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name })); - -export const clsConfig: ClsModuleOptions = { - middleware: { - mount: true, - generateId: true, - setup: (cls, req: Request, res: Response) => { - const headerValues = req.headers[ImmichHeader.CID]; - const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues; - const cid = headerValue || cls.get(CLS_ID); - cls.set(CLS_ID, cid); - res.header(ImmichHeader.CID, cid); - }, - }, -}; - -export const getBuildMetadata = () => ({ - build: process.env.IMMICH_BUILD, - buildUrl: process.env.IMMICH_BUILD_URL, - buildImage: process.env.IMMICH_BUILD_IMAGE, - buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL, - repository: process.env.IMMICH_REPOSITORY, - repositoryUrl: process.env.IMMICH_REPOSITORY_URL, - sourceRef: process.env.IMMICH_SOURCE_REF, - sourceCommit: process.env.IMMICH_SOURCE_COMMIT, - sourceUrl: process.env.IMMICH_SOURCE_URL, -}); - -const clientLicensePublicKeyProd = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -const clientLicensePublicKeyStaging = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -export const getClientLicensePublicKey = (): string => { - if (process.env.IMMICH_ENV === 'production') { - return clientLicensePublicKeyProd; - } - return clientLicensePublicKeyStaging; -}; - -const serverLicensePublicKeyProd = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -const serverLicensePublicKeyStaging = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -export const getServerLicensePublicKey = (): string => { - if (process.env.IMMICH_ENV === 'production') { - return serverLicensePublicKeyProd; - } - return serverLicensePublicKeyStaging; -}; diff --git a/server/src/constants.ts b/server/src/constants.ts index 422fa21a1b..e99970723a 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,10 +1,9 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { SemVer } from 'semver'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; -export const VECTORS_VERSION_RANGE = '0.2.x'; +export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; export const NEXT_RELEASE = 'NEXT_RELEASE'; @@ -20,77 +19,17 @@ export const serverVersion = new SemVer(version); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); -export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); -export const isDev = () => process.env.IMMICH_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; -export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; -const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283'; -export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT; export const citiesFile = 'cities500.txt'; -const buildFolder = process.env.IMMICH_BUILD_DATA || '/build'; - -const folders = { - geodata: join(buildFolder, 'geodata'), - web: join(buildFolder, 'www'), -}; - -export const resourcePaths = { - lockFile: join(buildFolder, 'build-lock.json'), - geodata: { - dateFile: join(folders.geodata, 'geodata-date.txt'), - admin1: join(folders.geodata, 'admin1CodesASCII.txt'), - admin2: join(folders.geodata, 'admin2Codes.txt'), - cities500: join(folders.geodata, citiesFile), - naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'), - }, - web: { - root: folders.web, - indexHtml: join(folders.web, 'index.html'), - }, -}; - -export const MOBILE_REDIRECT = 'app.immich:/'; +export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; -export enum AuthType { - PASSWORD = 'password', - OAUTH = 'oauth', -} - export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const FACE_THUMBNAIL_SIZE = 250; -export const supportedYearTokens = ['y', 'yy']; -export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM']; -export const supportedWeekTokens = ['W', 'WW']; -export const supportedDayTokens = ['d', 'dd']; -export const supportedHourTokens = ['h', 'hh', 'H', 'HH']; -export const supportedMinuteTokens = ['m', 'mm']; -export const supportedSecondTokens = ['s', 'ss', 'SSS']; -export const supportedPresetTokens = [ - '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}/{{MM}}-{{dd}}/{{filename}}', - '{{y}}/{{MMMM}}-{{dd}}/{{filename}}', - '{{y}}/{{MM}}/{{filename}}', - '{{y}}/{{MMM}}/{{filename}}', - '{{y}}/{{MMMM}}/{{filename}}', - '{{y}}/{{MM}}/{{dd}}/{{filename}}', - '{{y}}/{{MMMM}}/{{dd}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}-{{MMM}}-{{dd}}/{{filename}}', - '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}/{{filename}}', - '{{y}}/{{y}}-{{WW}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', - '{{y}}/{{y}}-{{MM}}/{{assetId}}', - '{{y}}/{{y}}-{{WW}}/{{assetId}}', - '{{album}}/{{filename}}', -]; - type ModelInfo = { dimSize: number }; export const CLIP_MODEL_INFO: Record = { RN101__openai: { dimSize: 512 }, diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 76b58a56ce..b91f2902d5 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -9,6 +9,7 @@ import { ActivityStatisticsResponseDto, } from 'src/dtos/activity.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ActivityService } from 'src/services/activity.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,19 +20,13 @@ export class ActivityController { constructor(private service: ActivityService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_READ }) getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise { return this.service.getAll(auth, dto); } - @Get('statistics') - @Authenticated() - getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { - return this.service.getStatistics(auth, dto); - } - @Post() - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_CREATE }) async createActivity( @Auth() auth: AuthDto, @Body() dto: ActivityCreateDto, @@ -44,9 +39,15 @@ export class ActivityController { return value; } + @Get('statistics') + @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) + getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { + return this.service.getStatistics(auth, dto); + } + @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_DELETE }) deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 1455aeec4b..49ec5a82ea 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -2,9 +2,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@ import { ApiTags } from '@nestjs/swagger'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -12,6 +12,7 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AlbumService } from 'src/services/album.service'; import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; @@ -21,25 +22,25 @@ import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; export class AlbumController { constructor(private service: AlbumService) {} - @Get('count') - @Authenticated() - getAlbumCount(@Auth() auth: AuthDto): Promise { - return this.service.getCount(auth); - } - @Get() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_READ }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { return this.service.getAll(auth, query); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_CREATE }) createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise { return this.service.create(auth, dto); } - @Authenticated({ sharedLink: true }) + @Get('statistics') + @Authenticated({ permission: Permission.ALBUM_STATISTICS }) + getAlbumStatistics(@Auth() auth: AuthDto): Promise { + return this.service.getStatistics(auth); + } + + @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) @Get(':id') getAlbumInfo( @Auth() auth: AuthDto, @@ -50,7 +51,7 @@ export class AlbumController { } @Patch(':id') - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_UPDATE }) updateAlbumInfo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -60,7 +61,7 @@ export class AlbumController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_DELETE }) deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { return this.service.delete(auth, id); } diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index 54144e78d5..4691ce05ef 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; 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 { UUIDParamDto } from 'src/validation'; @@ -12,25 +13,25 @@ export class APIKeyController { constructor(private service: APIKeyService) {} @Post() - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_CREATE }) createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKeys(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_UPDATE }) updateApiKey( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -40,7 +41,8 @@ export class APIKeyController { } @Delete(':id') - @Authenticated() + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.API_KEY_DELETE }) deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 48fea8b8a6..56e793975a 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -32,17 +32,18 @@ import { CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; -import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ImmichHeader, RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; import { AssetMediaService } from 'src/services/asset-media.service'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetMediaController { constructor( @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -93,8 +94,8 @@ export class AssetMediaController { @Put(':id/original') @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') - @Authenticated({ sharedLink: true }) @EndpointLifecycle({ addedAt: 'v1.106.0' }) + @Authenticated({ sharedLink: true }) async replaceAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8c70bed166..8a5b5fb0b6 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -13,14 +14,13 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; +import { RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; -import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetController { constructor(private service: AssetService) {} @@ -32,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } @@ -52,8 +53,8 @@ export class AssetController { } @Post('jobs') - @HttpCode(HttpStatus.NO_CONTENT) @Authenticated() + @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { return this.service.run(auth, dto); } @@ -72,13 +73,6 @@ export class AssetController { return this.service.deleteAll(auth, dto); } - @Put('stack/parent') - @HttpCode(HttpStatus.OK) - @Authenticated() - updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise { - return this.service.updateStackParent(auth, dto); - } - @Get(':id') @Authenticated({ sharedLink: true }) getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 7dcef9df5f..92fa59f6bf 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,11 +1,9 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, ChangePasswordDto, - ImmichCookie, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, @@ -13,6 +11,7 @@ import { ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType, ImmichCookie } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index e3330e9563..7d93bfd34d 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, 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 { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; import { UUIDParamDto } from 'src/validation'; @@ -12,13 +13,13 @@ export class FaceController { constructor(private service: PersonService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.FACE_READ }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { return this.service.getFacesById(auth, dto); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.FACE_UPDATE }) reassignFacesById( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9675cf6d3b..f10bf601b4 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -19,10 +19,10 @@ import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { SearchController } from 'src/controllers/search.controller'; -import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { StackController } from 'src/controllers/stack.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; @@ -31,6 +31,7 @@ import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; +import { ViewController } from 'src/controllers/view.controller'; export const controllers = [ APIKeyController, @@ -55,9 +56,9 @@ export const controllers = [ ReportController, SearchController, ServerController, - ServerInfoController, SessionController, SharedLinkController, + StackController, SyncController, SystemConfigController, SystemMetadataController, @@ -66,4 +67,5 @@ export const controllers = [ TrashController, UserAdminController, UserController, + ViewController, ]; diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab..7da19e207f 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index fd7a88b074..b8959ca288 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -4,11 +4,11 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, } from 'src/dtos/library.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { LibraryService } from 'src/services/library.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,27 +19,34 @@ export class LibraryController { constructor(private service: LibraryService) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) getAllLibraries(): Promise { return this.service.getAll(); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true }) createLibrary(@Body() dto: CreateLibraryDto): Promise { return this.service.create(dto); } + @Get(':id') + @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) + getLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); + } + @Put(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { return this.service.update(id, dto); } - @Get(':id') - @Authenticated({ admin: true }) - getLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.get(id); + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); } @Post(':id/validate') @@ -50,30 +57,16 @@ export class LibraryController { return this.service.validate(id, dto); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - deleteLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.delete(id); - } - @Get(':id/statistics') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(id); } @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(id, dto); - } - - @Post(':id/removeOffline') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - removeOfflineFiles(@Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(id); + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + scanLibrary(@Param() { id }: UUIDParamDto) { + return this.service.queueScan(id); } } diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index d6c26c58a0..88104e6b58 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -7,7 +7,6 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,12 +21,6 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated({ sharedLink: true }) - @Get('style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } - @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 9c5c22de43..710ca9f2f8 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -3,6 +3,7 @@ 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 { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MemoryService } from 'src/services/memory.service'; import { UUIDParamDto } from 'src/validation'; @@ -13,25 +14,25 @@ export class MemoryController { constructor(private service: MemoryService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_READ }) searchMemories(@Auth() auth: AuthDto): Promise { return this.service.search(auth); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_CREATE }) createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { return this.service.create(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_READ }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_UPDATE }) updateMemory( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -42,7 +43,7 @@ export class MemoryController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_DELETE }) deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index cc07022a93..3dd72dd73a 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,6 +1,7 @@ -import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -11,9 +12,9 @@ export class NotificationController { constructor(private service: NotificationService) {} @Post('test-email') - @HttpCode(200) + @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) - sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { + sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 764e67d676..b5b94030f2 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,16 +1,15 @@ -import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, - ImmichCookie, LoginResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType, ImmichCookie } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie } from 'src/utils/response'; @@ -58,6 +57,7 @@ export class OAuthController { } @Post('unlink') + @HttpCode(HttpStatus.OK) @Authenticated() unlinkOAuthAccount(@Auth() auth: AuthDto): Promise { return this.service.unlink(auth); diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 208d571464..6830fdd52f 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiQuery, ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; -import { PartnerDirection } from 'src/interfaces/partner.interface'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { PartnerService } from 'src/services/partner.service'; import { UUIDParamDto } from 'src/validation'; @@ -13,21 +13,19 @@ export class PartnerController { constructor(private service: PartnerService) {} @Get() - @ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true }) - @Authenticated() - // TODO: remove 'direction' and convert to full query dto + @Authenticated({ permission: Permission.PARTNER_READ }) getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise { return this.service.search(auth, dto); } @Post(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_CREATE }) createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.create(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_UPDATE }) updatePartner( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -37,7 +35,7 @@ export class PartnerController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_DELETE }) removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5f642dfa00..ba9a181c41 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -2,7 +2,6 @@ import { Body, Controller, Get, Inject, Next, Param, Post, Put, Query, Res } fro import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceUpdateDto, @@ -15,6 +14,7 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from 'src/dtos/person.dto'; +import { Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; @@ -30,31 +30,31 @@ export class PersonController { ) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise { return this.service.getAll(auth, withHidden); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_CREATE }) createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { return this.service.create(auth, dto); } @Put() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_UPDATE }) updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updateAll(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_UPDATE }) updatePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -64,14 +64,14 @@ export class PersonController { } @Get(':id/statistics') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_STATISTICS }) getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(auth, id); } @Get(':id/thumbnail') @FileResponse() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) async getPersonThumbnail( @Res() res: Response, @Next() next: NextFunction, @@ -81,14 +81,8 @@ export class PersonController { await sendFile(res, next, () => this.service.getThumbnail(auth, id), this.logger); } - @Get(':id/assets') - @Authenticated() - getPersonAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/reassign') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_REASSIGN }) reassignFaces( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -98,7 +92,7 @@ export class PersonController { } @Post(':id/merge') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_MERGE }) mergePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 688ff1c138..9fdb2746fc 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() @@ -62,6 +70,7 @@ export class SearchController { @Get('suggestions') @Authenticated() getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { - return this.service.getSearchSuggestions(auth, dto); + // TODO fix open api generation to indicate that results can be nullable + return this.service.getSearchSuggestions(auth, dto) as Promise; } } diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts deleted file mode 100644 index 812016f4eb..0000000000 --- a/server/src/controllers/server-info.controller.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { EndpointLifecycle } from 'src/decorators'; -import { - ServerAboutResponseDto, - ServerConfigDto, - ServerFeaturesDto, - ServerMediaTypesResponseDto, - ServerPingResponse, - ServerStatsResponseDto, - ServerStorageResponseDto, - ServerThemeDto, - ServerVersionResponseDto, -} from 'src/dtos/server.dto'; -import { Authenticated } from 'src/middleware/auth.guard'; -import { ServerService } from 'src/services/server.service'; -import { VersionService } from 'src/services/version.service'; - -@ApiTags('Server Info') -@Controller('server-info') -export class ServerInfoController { - constructor( - private service: ServerService, - private versionService: VersionService, - ) {} - - @Get('about') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Authenticated() - getAboutInfo(): Promise { - return this.service.getAboutInfo(); - } - - @Get('storage') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Authenticated() - getStorage(): Promise { - return this.service.getStorage(); - } - - @Get('ping') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - pingServer(): ServerPingResponse { - return this.service.ping(); - } - - @Get('version') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerVersion(): ServerVersionResponseDto { - return this.versionService.getVersion(); - } - - @Get('features') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerFeatures(): Promise { - return this.service.getFeatures(); - } - - @Get('theme') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getTheme(): Promise { - return this.service.getTheme(); - } - - @Get('config') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerConfig(): Promise { - return this.service.getConfig(); - } - - @Authenticated({ admin: true }) - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Get('statistics') - getServerStatistics(): Promise { - return this.service.getStatistics(); - } - - @Get('media-types') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getSupportedMediaTypes(): ServerMediaTypesResponseDto { - return this.service.getSupportedMediaTypes(); - } -} diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 0c615223e2..8327ff6d1d 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Put } from '@nestjs/common'; -import { ApiExcludeEndpoint, ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; +import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -10,6 +10,7 @@ import { ServerStatsResponseDto, ServerStorageResponseDto, ServerThemeDto, + ServerVersionHistoryResponseDto, ServerVersionResponseDto, } from 'src/dtos/server.dto'; import { Authenticated } from 'src/middleware/auth.guard'; @@ -26,57 +27,53 @@ export class ServerController { @Get('about') @Authenticated() - @ApiExcludeEndpoint() getAboutInfo(): Promise { return this.service.getAboutInfo(); } @Get('storage') @Authenticated() - @ApiExcludeEndpoint() getStorage(): Promise { return this.service.getStorage(); } @Get('ping') - @ApiExcludeEndpoint() pingServer(): ServerPingResponse { return this.service.ping(); } @Get('version') - @ApiExcludeEndpoint() getServerVersion(): ServerVersionResponseDto { return this.versionService.getVersion(); } + @Get('version-history') + getVersionHistory(): Promise { + return this.versionService.getVersionHistory(); + } + @Get('features') - @ApiExcludeEndpoint() getServerFeatures(): Promise { return this.service.getFeatures(); } @Get('theme') - @ApiExcludeEndpoint() getTheme(): Promise { return this.service.getTheme(); } @Get('config') - @ApiExcludeEndpoint() getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } - @Authenticated({ admin: true }) @Get('statistics') - @ApiExcludeEndpoint() + @Authenticated({ admin: true }) getServerStatistics(): Promise { return this.service.getStatistics(); } @Get('media-types') - @ApiExcludeEndpoint() getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index a1fb4779a5..d526c2e599 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -2,6 +2,7 @@ import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/co import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto } from 'src/dtos/session.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; import { UUIDParamDto } from 'src/validation'; @@ -12,21 +13,21 @@ export class SessionController { constructor(private service: SessionService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.SESSION_READ }) getSessions(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Delete() + @Authenticated({ permission: Permission.SESSION_DELETE }) @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() deleteAllSessions(@Auth() auth: AuthDto): Promise { return this.service.deleteAll(auth); } @Delete(':id') + @Authenticated({ permission: Permission.SESSION_DELETE }) @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index ffd6e0c969..59f81068d8 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -3,13 +3,14 @@ import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; -import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, } from 'src/dtos/shared-link.dto'; +import { ImmichCookie, Permission } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; @@ -22,7 +23,7 @@ export class SharedLinkController { constructor(private service: SharedLinkService) {} @Get() - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_READ }) getAllSharedLinks(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @@ -48,19 +49,19 @@ export class SharedLinkController { } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_READ }) getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_CREATE }) createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) { return this.service.create(auth, dto); } @Patch(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_UPDATE }) updateSharedLink( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -70,7 +71,7 @@ export class SharedLinkController { } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.SHARED_LINK_DELETE }) removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts new file mode 100644 index 0000000000..188952eba5 --- /dev/null +++ b/server/src/controllers/stack.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { StackService } from 'src/services/stack.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Stacks') +@Controller('stacks') +export class StackController { + constructor(private service: StackService) {} + + @Get() + @Authenticated({ permission: Permission.STACK_READ }) + searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { + return this.service.search(auth, query); + } + + @Post() + @Authenticated({ permission: Permission.STACK_CREATE }) + createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.STACK_DELETE }) + @HttpCode(HttpStatus.NO_CONTENT) + deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.STACK_READ }) + getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.STACK_UPDATE }) + updateStack( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: StackUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.STACK_DELETE }) + deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index e88f3dcb39..58e8bde87b 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,35 +1,40 @@ import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; +import { StorageTemplateService } from 'src/services/storage-template.service'; import { SystemConfigService } from 'src/services/system-config.service'; @ApiTags('System Config') @Controller('system-config') export class SystemConfigController { - constructor(private service: SystemConfigService) {} + constructor( + private service: SystemConfigService, + private storageTemplateService: StorageTemplateService, + ) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('defaults') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfigDefaults(): SystemConfigDto { return this.service.getDefaults(); } @Put() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateConfig(dto); + return this.service.updateSystemConfig(dto); } @Get('storage-template-options') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { - return this.service.getStorageTemplateOptions(); + return this.storageTemplateService.getStorageTemplateOptions(); } } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index 90e9f5b6a8..bca5c65d8e 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto'; +import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemMetadataService } from 'src/services/system-metadata.service'; @@ -10,20 +11,20 @@ export class SystemMetadataController { constructor(private service: SystemMetadataService) {} @Get('admin-onboarding') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) getAdminOnboarding(): Promise { return this.service.getAdminOnboarding(); } @Post('admin-onboarding') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_UPDATE, admin: true }) updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise { return this.service.updateAdminOnboarding(dto); } @Get('reverse-geocoding-state') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 71d826fcc5..cf6b8ac695 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,10 +1,16 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, +} from 'src/dtos/tag.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; import { UUIDParamDto } from 'src/validation'; @@ -15,58 +21,65 @@ export class TagController { constructor(private service: TagService) {} @Post() - @Authenticated() - createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise { + @Authenticated({ permission: Permission.TAG_CREATE }) + createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.TAG_READ }) getAllTags(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } - @Get(':id') - @Authenticated() - getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getById(auth, id); + @Put() + @Authenticated({ permission: Permission.TAG_CREATE }) + upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { + return this.service.upsert(auth, dto); } - @Patch(':id') - @Authenticated() - updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise { + @Put('assets') + @Authenticated({ permission: Permission.TAG_ASSET }) + bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { + return this.service.bulkTagAssets(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.TAG_READ }) + getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.TAG_UPDATE }) + updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') - @Authenticated() + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } - @Get(':id/assets') - @Authenticated() - getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: AssetIdsDto, - ): Promise { + @Body() dto: BulkIdsDto, + ): Promise { return this.service.addAssets(auth, id, dto); } @Delete(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) untagAssets( @Auth() auth: AuthDto, - @Body() dto: AssetIdsDto, + @Body() dto: BulkIdsDto, @Param() { id }: UUIDParamDto, - ): Promise { + ): Promise { return this.service.removeAssets(auth, id, dto); } } diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 53c62f70ed..92de84d346 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { AssetResponseDto } 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 { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; @@ -11,14 +12,14 @@ import { TimelineService } from 'src/services/timeline.service'; export class TimelineController { constructor(private service: TimelineService) {} - @Authenticated({ sharedLink: true }) @Get('buckets') + @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { return this.service.getTimeBuckets(auth, dto); } - @Authenticated({ sharedLink: true }) @Get('bucket') + @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { return this.service.getTimeBucket(auth, dto) as Promise; } diff --git a/server/src/controllers/trash.controller.ts b/server/src/controllers/trash.controller.ts index eae49d4ad1..dfcdfa6ba2 100644 --- a/server/src/controllers/trash.controller.ts +++ b/server/src/controllers/trash.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; 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 { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TrashService } from 'src/services/trash.service'; @@ -11,23 +13,23 @@ export class TrashController { constructor(private service: TrashService) {} @Post('empty') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() - emptyTrash(@Auth() auth: AuthDto): Promise { + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.ASSET_DELETE }) + emptyTrash(@Auth() auth: AuthDto): Promise { return this.service.empty(auth); } @Post('restore') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() - restoreTrash(@Auth() auth: AuthDto): Promise { + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.ASSET_DELETE }) + restoreTrash(@Auth() auth: AuthDto): Promise { return this.service.restore(auth); } @Post('restore/assets') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated() - restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.ASSET_DELETE }) + restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.restoreAssets(auth, dto); } } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index a4f3b3198c..d44115be2f 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -9,6 +9,7 @@ import { UserAdminSearchDto, UserAdminUpdateDto, } from 'src/dtos/user.dto'; +import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { UserAdminService } from 'src/services/user-admin.service'; import { UUIDParamDto } from 'src/validation'; @@ -19,25 +20,25 @@ export class UserAdminController { constructor(private service: UserAdminService) {} @Get() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise { return this.service.search(auth, dto); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true }) createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { return this.service.create(createUserDto); } @Get(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) updateUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -47,7 +48,7 @@ export class UserAdminController { } @Delete(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) deleteUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -57,13 +58,13 @@ export class UserAdminController { } @Get(':id/preferences') - @Authenticated() + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getPreferences(auth, id); } @Put(':id/preferences') - @Authenticated() + @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) updateUserPreferencesAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -73,7 +74,7 @@ export class UserAdminController { } @Post(':id/restore') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) 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 01b2258390..10076098d6 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -21,15 +21,16 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { UserService } from 'src/services/user.service'; import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Users') -@Controller(Route.USER) +@Controller(RouteKey.USER) export class UserController { constructor( private service: UserService, diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts new file mode 100644 index 0000000000..b5e281e093 --- /dev/null +++ b/server/src/controllers/view.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { ViewService } from 'src/services/view.service'; + +@ApiTags('View') +@Controller('view') +export class ViewController { + constructor(private service: ViewService) {} + + @Get('folder/unique-paths') + @Authenticated() + getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise { + return this.service.getUniqueOriginalPaths(auth); + } + + @Get('folder') + @Authenticated() + getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise { + return this.service.getAssetsByOriginalPath(auth, path); + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts deleted file mode 100644 index e857e9b5cc..0000000000 --- a/server/src/cores/access.core.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; -import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { setDifference, setIsEqual, setUnion } from 'src/utils/set'; - -export enum Permission { - ACTIVITY_CREATE = 'activity.create', - ACTIVITY_DELETE = 'activity.delete', - - // ASSET_CREATE = 'asset.create', - ASSET_READ = 'asset.read', - ASSET_UPDATE = 'asset.update', - ASSET_DELETE = 'asset.delete', - ASSET_RESTORE = 'asset.restore', - ASSET_SHARE = 'asset.share', - ASSET_VIEW = 'asset.view', - ASSET_DOWNLOAD = 'asset.download', - ASSET_UPLOAD = 'asset.upload', - - // ALBUM_CREATE = 'album.create', - ALBUM_READ = 'album.read', - ALBUM_UPDATE = 'album.update', - ALBUM_DELETE = 'album.delete', - ALBUM_ADD_ASSET = 'album.addAsset', - ALBUM_REMOVE_ASSET = 'album.removeAsset', - ALBUM_SHARE = 'album.share', - ALBUM_DOWNLOAD = 'album.download', - - AUTH_DEVICE_DELETE = 'authDevice.delete', - - ARCHIVE_READ = 'archive.read', - - TIMELINE_READ = 'timeline.read', - TIMELINE_DOWNLOAD = 'timeline.download', - - MEMORY_READ = 'memory.read', - MEMORY_WRITE = 'memory.write', - MEMORY_DELETE = 'memory.delete', - - PERSON_READ = 'person.read', - PERSON_WRITE = 'person.write', - PERSON_MERGE = 'person.merge', - PERSON_CREATE = 'person.create', - PERSON_REASSIGN = 'person.reassign', - - PARTNER_UPDATE = 'partner.update', -} - -let instance: AccessCore | null; - -export class AccessCore { - private constructor(private repository: IAccessRepository) {} - - static create(repository: IAccessRepository) { - if (!instance) { - instance = new AccessCore(repository); - } - - return instance; - } - - static reset() { - instance = null; - } - - requireUploadAccess(auth: AuthDto | null): AuthDto { - if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { - throw new UnauthorizedException(); - } - return auth; - } - - /** - * Check if user has access to all ids, for the given permission. - * Throws error if user does not have access to any of the ids. - */ - async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) { - ids = Array.isArray(ids) ? ids : [ids]; - const allowedIds = await this.checkAccess(auth, permission, ids); - if (!setIsEqual(new Set(ids), allowedIds)) { - throw new BadRequestException(`Not found or no ${permission} access`); - } - } - - /** - * Return ids that user has access to, for the given permission. - * Check is done for each id, and only allowed ids are returned. - * - * @returns Set - */ - async checkAccess(auth: AuthDto, permission: Permission, ids: Set | string[]): Promise> { - const idSet = Array.isArray(ids) ? new Set(ids) : ids; - if (idSet.size === 0) { - return new Set(); - } - - if (auth.sharedLink) { - return this.checkAccessSharedLink(auth.sharedLink, permission, idSet); - } - - return this.checkAccessOther(auth, permission, idSet); - } - - private async checkAccessSharedLink( - sharedLink: SharedLinkEntity, - permission: Permission, - ids: Set, - ): Promise> { - const sharedLinkId = sharedLink.id; - - switch (permission) { - case Permission.ASSET_READ: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_VIEW: { - return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ASSET_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ASSET_UPLOAD: { - return sharedLink.allowUpload ? ids : new Set(); - } - - case Permission.ASSET_SHARE: { - // TODO: fix this to not use sharedLink.userId for access control - return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids); - } - - case Permission.ALBUM_READ: { - return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - return sharedLink.allowDownload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - case Permission.ALBUM_ADD_ASSET: { - return sharedLink.allowUpload - ? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids) - : new Set(); - } - - default: { - return new Set(); - } - } - } - - private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set): Promise> { - switch (permission) { - // uses album id - case Permission.ACTIVITY_CREATE: { - return await this.repository.activity.checkCreateAccess(auth.user.id, ids); - } - - // uses activity id - case Permission.ACTIVITY_DELETE: { - const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids); - const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess( - auth.user.id, - setDifference(ids, isOwner), - ); - return setUnion(isOwner, isAlbumOwner); - } - - case Permission.ASSET_READ: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_SHARE: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.ASSET_VIEW: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_DOWNLOAD: { - const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await this.repository.asset.checkPartnerAccess( - auth.user.id, - setDifference(ids, isOwner, isAlbum), - ); - return setUnion(isOwner, isAlbum, isPartner); - } - - case Permission.ASSET_UPDATE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_DELETE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ASSET_RESTORE: { - return await this.repository.asset.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_READ: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_ADD_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_UPDATE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DELETE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_SHARE: { - return await this.repository.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.ALBUM_DOWNLOAD: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.VIEWER, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ALBUM_REMOVE_ASSET: { - const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await this.repository.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.EDITOR, - ); - return setUnion(isOwner, isShared); - } - - case Permission.ASSET_UPLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.ARCHIVE_READ: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.AUTH_DEVICE_DELETE: { - return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.TIMELINE_READ: { - const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.TIMELINE_DOWNLOAD: { - return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); - } - - case Permission.MEMORY_READ: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_WRITE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MEMORY_DELETE: { - return this.repository.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_READ: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_WRITE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_MERGE: { - return await this.repository.person.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_CREATE: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PERSON_REASSIGN: { - return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids); - } - - case Permission.PARTNER_UPDATE: { - return await this.repository.partner.checkUpdateAccess(auth.user.id, ids); - } - - default: { - return new Set(); - } - } - } -} diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 4f386a51ef..c49175172d 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,29 +1,19 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; -import { ImageFormat } from 'src/config'; import { APP_MEDIA_LOCATION } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.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 { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.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'; - -export enum StorageFolder { - ENCODED_VIDEO = 'encoded-video', - LIBRARY = 'library', - UPLOAD = 'upload', - PROFILE = 'profile', - THUMBNAILS = 'thumbs', -} - -export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); -export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); +import { getAssetFiles } from 'src/utils/asset.util'; +import { getConfig } from 'src/utils/config'; export interface MoveRequest { entityId: string; @@ -42,21 +32,20 @@ export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDE let instance: StorageCore | null; export class StorageCore { - private configCore; private constructor( private assetRepository: IAssetRepository, + private configRepository: IConfigRepository, private cryptoRepository: ICryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, + private systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, - ) { - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } + ) {} static create( assetRepository: IAssetRepository, + configRepository: IConfigRepository, cryptoRepository: ICryptoRepository, moveRepository: IMoveRepository, personRepository: IPersonRepository, @@ -67,6 +56,7 @@ export class StorageCore { if (!instance) { instance = new StorageCore( assetRepository, + configRepository, cryptoRepository, moveRepository, personRepository, @@ -125,17 +115,15 @@ export class StorageCore { return normalizedPath.startsWith(normalizedAppMediaLocation); } - static isGeneratedAsset(path: string) { - return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR); - } - async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { - const { id: entityId, previewPath, thumbnailPath } = asset; + const { id: entityId, files } = asset; + const { thumbnailFile, previewFile } = getAssetFiles(files); + const oldFile = pathType === AssetPathType.PREVIEW ? previewFile : thumbnailFile; return this.moveFile({ entityId, pathType, - oldPath: pathType === AssetPathType.PREVIEW ? previewPath : thumbnailPath, - newPath: StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, format), + oldPath: oldFile?.path || null, + newPath: StorageCore.getImagePath(asset, pathType, format), }); } @@ -254,7 +242,12 @@ export class StorageCore { this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`); return false; } - const config = await this.configCore.getConfig({ withCache: true }); + const repos = { + configRepo: this.configRepository, + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }; + const config = await getConfig(repos, { withCache: true }); if (assetInfo && config.storageTemplate.hashVerificationEnabled) { const { checksum } = assetInfo; const newChecksum = await this.cryptoRepository.hashFile(newPath); @@ -285,10 +278,10 @@ export class StorageCore { return this.assetRepository.update({ id, originalPath: newPath }); } case AssetPathType.PREVIEW: { - return this.assetRepository.update({ id, previewPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: newPath }); } case AssetPathType.THUMBNAIL: { - return this.assetRepository.update({ id, thumbnailPath: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: newPath }); } case AssetPathType.ENCODED_VIDEO: { return this.assetRepository.update({ id, encodedVideoPath: newPath }); diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts deleted file mode 100644 index 10fdb45637..0000000000 --- a/server/src/cores/system-config.core.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import AsyncLock from 'async-lock'; -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; -import { load as loadYaml } from 'js-yaml'; -import * as _ from 'lodash'; -import { Subject } from 'rxjs'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { getKeysDeep, unsetDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; - -export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; - -let instance: SystemConfigCore | null; - -@Injectable() -export class SystemConfigCore { - private readonly asyncLock = new AsyncLock(); - private config: SystemConfig | null = null; - private lastUpdated: number | null = null; - - config$ = new Subject(); - - private constructor( - private repository: ISystemMetadataRepository, - private logger: ILoggerRepository, - ) {} - - static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { - if (!instance) { - instance = new SystemConfigCore(repository, logger); - } - return instance; - } - - static reset() { - instance = null; - } - - async getConfig({ withCache }: { withCache: boolean }): Promise { - if (!withCache || !this.config) { - const lastUpdated = this.lastUpdated; - await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { - if (lastUpdated === this.lastUpdated) { - this.config = await this.buildConfig(); - this.lastUpdated = Date.now(); - } - }); - } - - return this.config!; - } - - async updateConfig(newConfig: SystemConfig): Promise { - // get the difference between the new config and the default config - const partialConfig: DeepPartial = {}; - for (const property of getKeysDeep(defaults)) { - const newValue = _.get(newConfig, property); - const isEmpty = newValue === undefined || newValue === null || newValue === ''; - const defaultValue = _.get(defaults, property); - const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - - if (isEmpty || isEqual) { - continue; - } - - _.set(partialConfig, property, newValue); - } - - await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); - } - - isUsingConfigFile() { - return !!process.env.IMMICH_CONFIG_FILE; - } - - private async buildConfig() { - // load partial - const partial = this.isUsingConfigFile() - ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - - // merge with defaults - const config = _.cloneDeep(defaults); - for (const property of getKeysDeep(partial)) { - _.set(config, property, _.get(partial, property)); - } - - // check for extra properties - const unknownKeys = _.cloneDeep(config); - for (const property of getKeysDeep(defaults)) { - unsetDeep(unknownKeys, property); - } - - if (!_.isEmpty(unknownKeys)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); - } - - // validate full config - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - if (this.isUsingConfigFile()) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } else { - this.logger.error('Validation error', errors); - } - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); - } - - return config; - } - - private async loadFromFile(filepath: string) { - try { - const file = await this.repository.readFile(filepath); - return loadYaml(file.toString()) as unknown; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - this.logger.error(error); - throw error; - } - } -} diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts deleted file mode 100644 index 153463a9cc..0000000000 --- a/server/src/cores/user.core.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import sanitize from 'sanitize-filename'; -import { SALT_ROUNDS } from 'src/constants'; -import { UserEntity } from 'src/entities/user.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; - -let instance: UserCore | null; - -export class UserCore { - private constructor( - private cryptoRepository: ICryptoRepository, - private userRepository: IUserRepository, - ) {} - - static create(cryptoRepository: ICryptoRepository, userRepository: IUserRepository) { - if (!instance) { - instance = new UserCore(cryptoRepository, userRepository); - } - - return instance; - } - - static reset() { - instance = null; - } - - async createUser(dto: Partial & { email: string }): Promise { - const user = await this.userRepository.getByEmail(dto.email); - if (user) { - throw new BadRequestException('User exists'); - } - - if (!dto.isAdmin) { - const localAdmin = await this.userRepository.getAdmin(); - if (!localAdmin) { - throw new BadRequestException('The first registered account must the administrator.'); - } - } - - const payload: Partial = { ...dto }; - if (payload.password) { - payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); - } - if (payload.storageLabel) { - payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); - } - - return this.userRepository.create(payload); - } -} diff --git a/server/src/database.config.ts b/server/src/database.config.ts deleted file mode 100644 index 9cc317a734..0000000000 --- a/server/src/database.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; -import { DataSource } from 'typeorm'; -import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; - -const url = process.env.DB_URL; -const urlOrParts = url - ? { url } - : { - host: process.env.DB_HOSTNAME || 'database', - port: Number.parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USERNAME || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_DATABASE_NAME || 'immich', - }; - -/* eslint unicorn/prefer-module: "off" -- We can fix this when migrating to ESM*/ -export const databaseConfig: PostgresConnectionOptions = { - type: 'postgres', - entities: [__dirname + '/entities/*.entity.{js,ts}'], - migrations: [__dirname + '/migrations/*.{js,ts}'], - subscribers: [__dirname + '/subscribers/*.{js,ts}'], - migrationsRun: false, - synchronize: false, - connectTimeoutMS: 10_000, // 10 seconds - parseInt8: true, - ...urlOrParts, -}; - -/** - * @deprecated - DO NOT USE THIS - * - * this export is ONLY to be used for TypeORM commands in package.json#scripts - */ -export const dataSource = new DataSource({ ...databaseConfig, host: 'localhost' }); - -export const getVectorExtension = () => - process.env.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 1c632e549a..db755c5ff9 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { ServerEvent } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; +import { MetadataKey } from 'src/enum'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -88,27 +86,6 @@ export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { return Chunked({ ...options, mergeFn: setUnion }); } -// https://stackoverflow.com/a/74898678 -export function DecorateAll( - decorator: ( - target: any, - propertyKey: string, - descriptor: TypedPropertyDescriptor, - ) => TypedPropertyDescriptor | void, -) { - return (target: any) => { - const descriptors = Object.getOwnPropertyDescriptors(target.prototype); - for (const [propName, descriptor] of Object.entries(descriptors)) { - const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor'; - if (!isMethod) { - continue; - } - decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor); - Object.defineProperty(target.prototype, propName, descriptor); - } - }; -} - const UUID = '00000000-0000-4000-a000-000000000000'; export const DummyValue = { @@ -130,17 +107,20 @@ export interface GenerateSqlQueries { params: unknown[]; } +export const Telemetry = (options: { enabled?: boolean }) => + SetMetadata(MetadataKey.TELEMETRY_ENABLED, options?.enabled ?? true); + /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type HandlerOptions = { +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ - priority: number; + priority?: number; }; -export const EventHandlerOptions = (options: HandlerOptions) => SetMetadata(Metadata.EVENT_HANDLER_OPTIONS, options); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 21eb649e11..b12847ee62 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -5,8 +5,8 @@ import _ from 'lodash'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; -import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class AlbumInfoDto { @@ -95,7 +95,7 @@ export class GetAlbumsDto { assetId?: string; } -export class AlbumCountResponseDto { +export class AlbumStatisticsResponseDto { @ApiProperty({ type: 'integer' }) owned!: number; diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 1f4f855216..7e81ce8c60 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -1,10 +1,17 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Permission } from 'src/enum'; import { Optional } from 'src/validation'; export class APIKeyCreateDto { @IsString() @IsNotEmpty() @Optional() name?: string; + + @IsEnum(Permission, { each: true }) + @ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true }) + @ArrayMinSize(1) + permissions!: Permission[]; } export class APIKeyUpdateDto { @@ -23,4 +30,6 @@ export class APIKeyResponseDto { name!: string; createdAt!: Date; updatedAt!: Date; + @ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true }) + permissions!: Permission[]; } diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts index 33fa080bc1..5cd9b7e7d9 100644 --- a/server/src/dtos/asset-media-response.dto.ts +++ b/server/src/dtos/asset-media-response.dto.ts @@ -26,6 +26,7 @@ export class AssetBulkUploadCheckResult { action!: AssetUploadAction; reason?: AssetRejectReason; assetId?: string; + isTrashed?: boolean; } export class AssetBulkUploadCheckResponseDto { diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index e9e346c4cb..c62857da65 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isVisible?: boolean; - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 03fa2f8b3d..ed92208182 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -11,8 +11,9 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { AssetType } from 'src/enum'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -21,7 +22,6 @@ export class SanitizedAssetResponseDto { type!: AssetType; thumbhash!: string | null; originalMimeType?: string; - resized!: boolean; localDateTime!: Date; duration!: string; livePhotoVideoId?: string | null; @@ -51,11 +51,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; - stackParentId?: string | null; - stack?: AssetResponseDto[]; - @ApiProperty({ type: 'integer' }) - stackCount!: number | null; + stack?: AssetStackResponseDto | null; duplicateId?: string | null; + + @PropertyLifecycle({ deprecatedAt: 'v1.113.0' }) + resized?: boolean; +} + +export class AssetStackResponseDto { + id!: string; + + primaryAssetId!: string; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; } export type AssetMapOptions = { @@ -82,6 +91,18 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] return result; }; +const mapStack = (entity: AssetEntity) => { + if (!entity.stack) { + return null; + } + + return { + id: entity.stack.id, + primaryAssetId: entity.stack.primaryAssetId, + assetCount: entity.stack.assetCount ?? entity.stack.assets.length, + }; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -92,7 +113,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash?.toString('base64') ?? null, localDateTime: entity.localDateTime, - resized: !!entity.previewPath, duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -111,7 +131,6 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalPath: entity.originalPath, originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), - resized: !!entity.previewPath, thumbhash: entity.thumbhash?.toString('base64') ?? null, fileCreatedAt: entity.fileCreatedAt, fileModifiedAt: entity.fileModifiedAt, @@ -124,20 +143,15 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, - tags: entity.tags?.map(mapTag), + tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), - stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, - stack: withStack - ? entity.stack?.assets - ?.filter((a) => a.id !== entity.stack?.primaryAssetId) - ?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth })) - : undefined, - stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null, + stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, duplicateId: entity.duplicateId, + resized: true, }; } diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 4d2ddb0a3e..42d6d7d745 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -9,10 +9,12 @@ import { IsNotEmpty, IsPositive, IsString, + Max, + Min, ValidateIf, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; import { AssetStats } from 'src/interfaces/asset.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; @@ -46,18 +48,18 @@ export class UpdateAssetBase { @IsLongitude() @IsNotEmpty() longitude?: number; + + @Optional() + @IsInt() + @Max(5) + @Min(0) + rating?: number; } export class AssetBulkUpdateDto extends UpdateAssetBase { @ValidateUUID({ each: true }) ids!: string[]; - @ValidateUUID({ optional: true }) - stackParentId?: string; - - @ValidateBoolean({ optional: true }) - removeParent?: boolean; - @Optional() duplicateId?: string | null; } @@ -66,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; + + @ValidateUUID({ optional: true, nullable: true }) + livePhotoVideoId?: string | null; } export class RandomAssetsDto { @@ -87,8 +92,9 @@ export class AssetIdsDto { } export enum AssetJobName { - REGENERATE_THUMBNAIL = 'regenerate-thumbnail', + REFRESH_FACES = 'refresh-faces', REFRESH_METADATA = 'refresh-metadata', + REGENERATE_THUMBNAIL = 'regenerate-thumbnail', TRANSCODE_VIDEO = 'transcode-video', } diff --git a/server/src/dtos/audit.dto.ts b/server/src/dtos/audit.dto.ts index e83efca768..434da46eba 100644 --- a/server/src/dtos/audit.dto.ts +++ b/server/src/dtos/audit.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { EntityType } from 'src/entities/audit.entity'; -import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; +import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 6488901fb6..b2bf1b8bcc 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -5,28 +5,9 @@ import { APIKeyEntity } from 'src/entities/api-key.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { ImmichCookie } from 'src/enum'; import { toEmail } from 'src/validation'; -export enum ImmichCookie { - ACCESS_TOKEN = 'immich_access_token', - AUTH_TYPE = 'immich_auth_type', - IS_AUTHENTICATED = 'immich_is_authenticated', - SHARED_LINK_TOKEN = 'immich_shared_link_token', -} - -export enum ImmichHeader { - API_KEY = 'x-api-key', - USER_TOKEN = 'x-immich-user-token', - SESSION_TOKEN = 'x-immich-session-token', - SHARED_LINK_KEY = 'x-immich-share-key', - CHECKSUM = 'x-immich-checksum', - CID = 'x-immich-cid', -} - -export enum ImmichQuery { - SHARED_LINK_KEY = 'key', -} - export type CookieResponse = { isSecure: boolean; values: Array<{ key: ImmichCookie; value: string }>; diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 73863fa95d..09976b3213 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,5 +1,5 @@ import { IsNotEmpty } from 'class-validator'; -import { groupBy } from 'lodash'; +import { groupBy, sortBy } from 'lodash'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ValidateUUID } from 'src/validation'; @@ -19,7 +19,8 @@ export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateRespo const grouped = groupBy(assets, (a) => a.duplicateId); - for (const [duplicateId, assets] of Object.entries(grouped)) { + for (const [duplicateId, unsortedAssets] of Object.entries(grouped)) { + const assets = sortBy(unsortedAssets, (asset) => asset.localDateTime); result.push({ duplicateId, assets }); } diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts new file mode 100644 index 0000000000..6c238252a6 --- /dev/null +++ b/server/src/dtos/env.dto.ts @@ -0,0 +1,190 @@ +import { Transform, Type } from 'class-transformer'; +import { IsEnum, IsInt, IsString } from 'class-validator'; +import { ImmichEnvironment, LogLevel } from 'src/enum'; +import { IsIPRange, Optional, ValidateBoolean } from 'src/validation'; + +export class EnvDto { + @IsInt() + @Optional() + @Type(() => Number) + IMMICH_API_METRICS_PORT?: number; + + @IsString() + @Optional() + IMMICH_BUILD_DATA?: string; + + @IsString() + @Optional() + IMMICH_BUILD?: string; + + @IsString() + @Optional() + IMMICH_BUILD_URL?: string; + + @IsString() + @Optional() + IMMICH_BUILD_IMAGE?: string; + + @IsString() + @Optional() + IMMICH_BUILD_IMAGE_URL?: string; + + @IsString() + @Optional() + IMMICH_CONFIG_FILE?: string; + + @IsEnum(ImmichEnvironment) + @Optional() + IMMICH_ENV?: ImmichEnvironment; + + @IsString() + @Optional() + IMMICH_HOST?: string; + + @ValidateBoolean({ optional: true }) + IMMICH_IGNORE_MOUNT_CHECK_ERRORS?: boolean; + + @IsEnum(LogLevel) + @Optional() + IMMICH_LOG_LEVEL?: LogLevel; + + @IsInt() + @Optional() + @Type(() => Number) + IMMICH_MICROSERVICES_METRICS_PORT?: number; + + @IsInt() + @Optional() + @Type(() => Number) + IMMICH_PORT?: number; + + @IsString() + @Optional() + IMMICH_REPOSITORY?: string; + + @IsString() + @Optional() + IMMICH_REPOSITORY_URL?: string; + + @IsString() + @Optional() + IMMICH_SOURCE_REF?: string; + + @IsString() + @Optional() + IMMICH_SOURCE_COMMIT?: string; + + @IsString() + @Optional() + IMMICH_SOURCE_URL?: string; + + @IsString() + @Optional() + IMMICH_TELEMETRY_INCLUDE?: string; + + @IsString() + @Optional() + IMMICH_TELEMETRY_EXCLUDE?: string; + + @IsString() + @Optional() + IMMICH_THIRD_PARTY_SOURCE_URL?: string; + + @IsString() + @Optional() + IMMICH_THIRD_PARTY_BUG_FEATURE_URL?: string; + + @IsString() + @Optional() + IMMICH_THIRD_PARTY_DOCUMENTATION_URL?: string; + + @IsString() + @Optional() + IMMICH_THIRD_PARTY_SUPPORT_URL?: string; + + @IsIPRange({ requireCIDR: false }, { each: true }) + @Transform(({ value }) => + value && typeof value === 'string' + ? value + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + : value, + ) + @Optional() + IMMICH_TRUSTED_PROXIES?: string[]; + + @IsString() + @Optional() + IMMICH_WORKERS_INCLUDE?: string; + + @IsString() + @Optional() + IMMICH_WORKERS_EXCLUDE?: string; + + @IsString() + @Optional() + DB_DATABASE_NAME?: string; + + @IsString() + @Optional() + DB_HOSTNAME?: string; + + @IsString() + @Optional() + DB_PASSWORD?: string; + + @IsInt() + @Optional() + @Type(() => Number) + DB_PORT?: number; + + @ValidateBoolean({ optional: true }) + DB_SKIP_MIGRATIONS?: boolean; + + @IsString() + @Optional() + DB_URL?: string; + + @IsString() + @Optional() + DB_USERNAME?: string; + + @IsEnum(['pgvector', 'pgvecto.rs']) + @Optional() + DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs'; + + @IsString() + @Optional() + NO_COLOR?: string; + + @IsString() + @Optional() + REDIS_HOSTNAME?: string; + + @IsInt() + @Optional() + @Type(() => Number) + REDIS_PORT?: number; + + @IsInt() + @Optional() + @Type(() => Number) + REDIS_DBINDEX?: number; + + @IsString() + @Optional() + REDIS_USERNAME?: string; + + @IsString() + @Optional() + REDIS_PASSWORD?: string; + + @IsString() + @Optional() + REDIS_SOCKET?: string; + + @IsString() + @Optional() + REDIS_URL?: string; +} diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 6724de98f5..079891ae56 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -25,6 +25,7 @@ export class ExifResponseDto { country?: string | null = null; description?: string | null = null; projectionType?: string | null = null; + rating?: number | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -50,6 +51,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { country: entity.country, description: entity.description, projectionType: entity.projectionType, + rating: entity.rating, }; } @@ -62,5 +64,6 @@ export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { projectionType: entity.projectionType, exifImageWidth: entity.exifImageWidth, exifImageHeight: entity.exifImageHeight, + rating: entity.rating, }; } diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf..31612bd8a4 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +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 { ValidateBoolean } from 'src/validation'; @@ -17,7 +18,13 @@ export class JobCommandDto { command!: JobCommand; @ValidateBoolean({ optional: true }) - force!: boolean; + force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit +} + +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; } export class JobCountsDto { @@ -90,4 +97,7 @@ export class AllJobStatusResponseDto implements Record @ApiProperty({ type: JobStatusDto }) [QueueName.NOTIFICATION]!: JobStatusDto; + + @ApiProperty({ type: JobStatusDto }) + [QueueName.BACKUP_DATABASE]!: JobStatusDto; } diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index b9578a2c37..7fb363dd9a 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { LibraryEntity } from 'src/entities/library.entity'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @ValidateUUID() @@ -48,12 +48,16 @@ export class UpdateLibraryDto { exclusionPatterns?: string[]; } -export class CrawlOptionsDto { - pathsToCrawl!: string[]; - includeHidden? = false; +export interface CrawlOptionsDto { + pathsToCrawl: string[]; + includeHidden?: boolean; exclusionPatterns?: string[]; } +export interface WalkOptionsDto extends CrawlOptionsDto { + take: number; +} + export class ValidateLibraryDto { @Optional() @IsString({ each: true }) @@ -85,14 +89,6 @@ export class LibrarySearchDto { userId?: string; } -export class ScanLibraryDto { - @ValidateBoolean({ optional: true }) - refreshModifiedFiles?: boolean; - - @ValidateBoolean({ optional: true }) - refreshAllFiles?: boolean; -} - export class LibraryResponseDto { id!: string; ownerId!: string; diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index c9db4b04e0..5d2e13a9ad 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class MemoryBaseDto { diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index dffacc793d..f8b9e2043f 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -27,14 +27,14 @@ export class DuplicateDetectionConfig extends TaskConfig { export class FacialRecognitionConfig extends ModelConfig { @IsNumber() - @Min(0) + @Min(0.1) @Max(1) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) minScore!: number; @IsNumber() - @Min(0) + @Min(0.1) @Max(2) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts new file mode 100644 index 0000000000..34b3923580 --- /dev/null +++ b/server/src/dtos/notification.dto.ts @@ -0,0 +1,3 @@ +export class TestEmailResponseDto { + messageId!: string; +} diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 573651c3f3..94ee52d916 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,10 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsInt, IsNotEmpty, 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'; export class PersonCreateDto { @@ -20,7 +22,7 @@ export class PersonCreateDto { * Note: the mobile app cannot currently set the birth date to null. */ @ApiProperty({ format: 'date' }) - @MaxDateString(() => new Date(), { message: 'Birth date cannot be in the future' }) + @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') @Optional({ nullable: true }) birthDate?: string | null; @@ -112,6 +114,8 @@ export class AssetFaceWithoutPersonResponseDto { boundingBoxY1!: number; @ApiProperty({ type: 'integer' }) boundingBoxY2!: number; + @ApiProperty({ enum: SourceType, enumName: 'SourceType' }) + sourceType?: SourceType; } export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto { @@ -175,6 +179,7 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe boundingBoxX2: face.boundingBoxX2, boundingBoxY1: face.boundingBoxY1, boundingBoxY2: face.boundingBoxY2, + sourceType: face.sourceType, }; } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 0874300d5f..5c5dce1a11 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { AssetOrder } from 'src/entities/album.entity'; -import { AssetType } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; +import { AssetOrder, AssetType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @@ -75,40 +75,29 @@ class BaseSearchDto { takenAfter?: Date; @IsString() - @IsNotEmpty() - @Optional() - city?: string; + @Optional({ nullable: true, emptyToNull: true }) + city?: string | null; + + @IsString() + @Optional({ nullable: true, emptyToNull: true }) + state?: string | null; @IsString() @IsNotEmpty() - @Optional() - state?: string; + @Optional({ nullable: true, emptyToNull: true }) + country?: string | null; @IsString() - @IsNotEmpty() - @Optional() - country?: string; - - @IsString() - @IsNotEmpty() - @Optional() + @Optional({ nullable: true, emptyToNull: true }) make?: string; @IsString() - @IsNotEmpty() - @Optional() - model?: string; + @Optional({ nullable: true, emptyToNull: true }) + model?: string | null; @IsString() - @IsNotEmpty() - @Optional() - lensModel?: string; - - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; + @Optional({ nullable: true, emptyToNull: true }) + lensModel?: string | null; @IsInt() @Min(1) @@ -124,7 +113,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -138,12 +135,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() @@ -173,12 +164,24 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { @@ -242,6 +245,10 @@ export class SearchSuggestionRequestDto { @IsString() @Optional() model?: string; + + @ValidateBoolean({ optional: true }) + @PropertyLifecycle({ addedAt: 'v111.0.0' }) + includeNull?: boolean; } class SearchFacetCountResponseDto { diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 9c18b0b4fe..e540483351 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -30,6 +30,11 @@ export class ServerAboutResponseDto { exiftool?: string; licensed!: boolean; + + thirdPartySourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySupportUrl?: string; } export class ServerStorageResponseDto { @@ -63,6 +68,12 @@ export class ServerVersionResponseDto { } } +export class ServerVersionHistoryResponseDto { + id!: string; + createdAt!: Date; + version!: string; +} + export class UsageByUserDto { @ApiProperty({ type: 'string' }) userId!: string; @@ -121,6 +132,8 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + mapDarkStyleUrl!: string; + mapLightStyleUrl!: string; } export class ServerFeaturesDto { @@ -131,6 +144,7 @@ export class ServerFeaturesDto { map!: boolean; trash!: boolean; reverseGeocoding!: boolean; + importFaces!: boolean; oauth!: boolean; oauthAutoLaunch!: boolean; passwordLogin!: boolean; diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 9a90901d27..b97791db58 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -3,7 +3,8 @@ import { IsEnum, IsString } from 'class-validator'; import _ from 'lodash'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { SharedLinkType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class SharedLinkCreateDto { diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index 3ff04ee5ed..3b867b02fe 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -1,9 +1,38 @@ +import { ArrayMinSize } from 'class-validator'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackEntity } from 'src/entities/stack.entity'; import { ValidateUUID } from 'src/validation'; -export class UpdateStackParentDto { - @ValidateUUID() - oldParentId!: string; - - @ValidateUUID() - newParentId!: string; +export class StackCreateDto { + /** first asset becomes the primary */ + @ValidateUUID({ each: true }) + @ArrayMinSize(2) + assetIds!: string[]; } + +export class StackSearchDto { + primaryAssetId?: string; +} + +export class StackUpdateDto { + @ValidateUUID({ optional: true }) + primaryAssetId?: string; +} + +export class StackResponseDto { + id!: string; + primaryAssetId!: string; + assets!: AssetResponseDto[]; +} + +export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => { + const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId); + const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId); + + return { + id: stack.id, + primaryAssetId: stack.primaryAssetId, + assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })), + }; +}; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 98acb495ce..7e7a8e0879 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -18,20 +18,20 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; +import { SystemConfig } from 'src/config'; +import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { AudioCodec, CQMode, Colorspace, ImageFormat, LogLevel, - SystemConfig, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, -} from 'src/config'; -import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean, validateCronExpression } from 'src/validation'; @@ -46,6 +46,30 @@ const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enab const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled; const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabled; +const isDatabaseBackupEnabled = (config: DatabaseBackupConfig) => config.enabled; + +export class DatabaseBackupConfig { + @ValidateBoolean() + enabled!: boolean; + + @ValidateIf(isDatabaseBackupEnabled) + @IsNotEmpty() + @Validate(CronValidator, { message: 'Invalid cron expression' }) + @IsString() + cronExpression!: string; + + @IsInt() + @IsPositive() + @IsNotEmpty() + keepLastAmount!: number; +} + +export class SystemConfigBackupsDto { + @Type(() => DatabaseBackupConfig) + @ValidateNested() + @IsObject() + database!: DatabaseBackupConfig; +} export class SystemConfigFFmpegDto { @IsInt() @@ -296,10 +320,12 @@ class SystemConfigMapDto { @ValidateBoolean() enabled!: boolean; - @IsString() + @IsNotEmpty() + @IsUrl() lightStyle!: string; - @IsString() + @IsNotEmpty() + @IsUrl() darkStyle!: string; } @@ -375,8 +401,21 @@ class SystemConfigReverseGeocodingDto { enabled!: boolean; } +class SystemConfigFacesDto { + @IsBoolean() + import!: boolean; +} + +class SystemConfigMetadataDto { + @Type(() => SystemConfigFacesDto) + @ValidateNested() + @IsObject() + faces!: SystemConfigFacesDto; +} + class SystemConfigServerDto { - @IsString() + @ValidateIf((_, value: string) => value !== '') + @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }) externalDomain!: string; @IsString() @@ -458,26 +497,10 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - previewSize!: number; + format!: ImageFormat; @IsInt() @Min(1) @@ -486,6 +509,24 @@ class SystemConfigImageDto { @ApiProperty({ type: 'integer' }) quality!: number; + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + size!: number; +} + +export class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; + @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; @@ -514,6 +555,11 @@ class SystemConfigUserDto { } export class SystemConfigDto implements SystemConfig { + @Type(() => SystemConfigBackupsDto) + @ValidateNested() + @IsObject() + backup!: SystemConfigBackupsDto; + @Type(() => SystemConfigFFmpegDto) @ValidateNested() @IsObject() @@ -554,6 +600,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() reverseGeocoding!: SystemConfigReverseGeocodingDto; + @Type(() => SystemConfigMetadataDto) + @ValidateNested() + @IsObject() + metadata!: SystemConfigMetadataDto; + @Type(() => SystemConfigStorageTemplateDto) @ValidateNested() @IsObject() diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 1094d70df3..cff11962d7 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,38 +1,66 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; -import { Optional } from 'src/validation'; +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'; -export class CreateTagDto { +export class TagCreateDto { @IsString() @IsNotEmpty() name!: string; - @IsEnum(TagType) - @IsNotEmpty() - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: TagType; + @ValidateUUID({ optional: true, nullable: true }) + parentId?: string | null; + + @IsHexColor() + @Optional({ nullable: true, emptyToNull: true }) + color?: string; } -export class UpdateTagDto { - @IsString() - @Optional() - name?: string; +export class TagUpdateDto { + @Optional({ nullable: true, emptyToNull: true }) + @IsHexColor() + @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + color?: string | null; +} + +export class TagUpsertDto { + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + tags!: string[]; +} + +export class TagBulkAssetsDto { + @ValidateUUID({ each: true }) + tagIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class TagBulkAssetsResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; } export class TagResponseDto { id!: string; - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: string; + parentId?: string; name!: string; - userId!: string; + value!: string; + createdAt!: Date; + updatedAt!: Date; + color?: string; } export function mapTag(entity: TagEntity): TagResponseDto { return { id: entity.id, - type: entity.type, - name: entity.name, - userId: entity.userId, + parentId: entity.parentId ?? undefined, + name: entity.value.split('/').at(-1) as string, + value: entity.value, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a551260136..dd7a01df35 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { AssetOrder } from 'src/entities/album.entity'; +import { AssetOrder } from 'src/enum'; import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; @@ -19,6 +19,9 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; + @ValidateUUID({ optional: true }) + tagId?: string; + @ValidateBoolean({ optional: true }) isArchived?: boolean; diff --git a/server/src/dtos/trash.dto.ts b/server/src/dtos/trash.dto.ts new file mode 100644 index 0000000000..d8e139bff2 --- /dev/null +++ b/server/src/dtos/trash.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TrashResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; +} diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 29cefcc10c..8de7021eaf 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; -import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity'; +import { UserPreferences } from 'src/entities/user-metadata.entity'; +import { UserAvatarColor } from 'src/enum'; import { Optional, ValidateBoolean } from 'src/validation'; class AvatarUpdate { @@ -11,11 +12,40 @@ class AvatarUpdate { color?: UserAvatarColor; } -class MemoryUpdate { +class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; } +class RatingsUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; +} + +class FoldersUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class PeopleUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + +class TagsUpdate { + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateBoolean({ optional: true }) + sidebarWeb?: boolean; +} + class EmailNotificationsUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; @@ -27,12 +57,15 @@ class EmailNotificationsUpdate { albumUpdate?: boolean; } -class DownloadUpdate { +class DownloadUpdate implements Partial { @Optional() @IsInt() @IsPositive() @ApiProperty({ type: 'integer' }) archiveSize?: number; + + @ValidateBoolean({ optional: true }) + includeEmbeddedVideos?: boolean; } class PurchaseUpdate { @@ -47,13 +80,33 @@ class PurchaseUpdate { export class UserPreferencesUpdateDto { @Optional() @ValidateNested() - @Type(() => AvatarUpdate) - avatar?: AvatarUpdate; + @Type(() => FoldersUpdate) + folders?: FoldersUpdate; @Optional() @ValidateNested() - @Type(() => MemoryUpdate) - memories?: MemoryUpdate; + @Type(() => MemoriesUpdate) + memories?: MemoriesUpdate; + + @Optional() + @ValidateNested() + @Type(() => PeopleUpdate) + people?: PeopleUpdate; + + @Optional() + @ValidateNested() + @Type(() => RatingsUpdate) + ratings?: RatingsUpdate; + + @Optional() + @ValidateNested() + @Type(() => TagsUpdate) + tags?: TagsUpdate; + + @Optional() + @ValidateNested() + @Type(() => AvatarUpdate) + avatar?: AvatarUpdate; @Optional() @ValidateNested() @@ -76,8 +129,27 @@ class AvatarResponse { color!: UserAvatarColor; } -class MemoryResponse { - enabled!: boolean; +class RatingsResponse { + enabled: boolean = false; +} + +class MemoriesResponse { + enabled: boolean = true; +} + +class FoldersResponse { + enabled: boolean = false; + sidebarWeb: boolean = false; +} + +class PeopleResponse { + enabled: boolean = true; + sidebarWeb: boolean = false; +} + +class TagsResponse { + enabled: boolean = true; + sidebarWeb: boolean = true; } class EmailNotificationsResponse { @@ -89,6 +161,8 @@ class EmailNotificationsResponse { class DownloadResponse { @ApiProperty({ type: 'integer' }) archiveSize!: number; + + includeEmbeddedVideos: boolean = false; } class PurchaseResponse { @@ -97,7 +171,11 @@ class PurchaseResponse { } export class UserPreferencesResponseDto implements UserPreferences { - memories!: MemoryResponse; + folders!: FoldersResponse; + memories!: MemoriesResponse; + people!: PeopleResponse; + ratings!: RatingsResponse; + tags!: TagsResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; download!: DownloadResponse; diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index b14662c844..16eea373e3 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -8,12 +8,6 @@ export class CreateProfileImageDto { export class CreateProfileImageResponseDto { userId!: string; + profileChangedAt!: Date; profileImagePath!: string; } - -export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto { - return { - userId: userId, - profileImagePath: profileImagePath, - }; -} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 54020a7397..593a7934bc 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; -import { UserAvatarColor, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; -import { UserEntity, UserStatus } from 'src/entities/user.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; @@ -31,6 +32,7 @@ export class UserResponseDto { profileImagePath!: string; @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) avatarColor!: UserAvatarColor; + profileChangedAt!: Date; } export class UserLicense { @@ -46,6 +48,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => { name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: getPreferences(entity).avatar.color, + profileChangedAt: entity.profileChangedAt, }; }; @@ -59,7 +62,6 @@ export class UserAdminCreateDto { @Transform(toEmail) email!: string; - @IsNotEmpty() @IsString() password!: string; diff --git a/server/src/emails/album-invite.email.tsx b/server/src/emails/album-invite.email.tsx index b804be0898..232ef5290d 100644 --- a/server/src/emails/album-invite.email.tsx +++ b/server/src/emails/album-invite.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumInviteEmail = ({ baseUrl, diff --git a/server/src/emails/album-update.email.tsx b/server/src/emails/album-update.email.tsx index d05631a772..0fb5ad931c 100644 --- a/server/src/emails/album-update.email.tsx +++ b/server/src/emails/album-update.email.tsx @@ -1,8 +1,8 @@ import { Img, Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const AlbumUpdateEmail = ({ baseUrl, albumName, recipientName, albumId, cid }: AlbumUpdateEmailProps) => ( diff --git a/server/src/emails/components/immich.layout.tsx b/server/src/emails/components/immich.layout.tsx index 8e6de2eebc..bb7a2aab65 100644 --- a/server/src/emails/components/immich.layout.tsx +++ b/server/src/emails/components/immich.layout.tsx @@ -1,6 +1,6 @@ import { Body, Container, Font, Head, Hr, Html, Img, Preview, Section, Tailwind, Text } from '@react-email/components'; import * as React from 'react'; -import { ImmichFooter } from './footer.template'; +import { ImmichFooter } from 'src/emails/components/footer.template'; interface ImmichLayoutProps { children: React.ReactNode; @@ -11,6 +11,7 @@ export const ImmichLayout = ({ children, preview }: ImmichLayoutProps) => ( ( diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index d6b3fc13e7..e031ac6b97 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -1,8 +1,8 @@ import { Link, Section, Text } from '@react-email/components'; import * as React from 'react'; +import { ImmichButton } from 'src/emails/components/button.component'; +import ImmichLayout from 'src/emails/components/immich.layout'; import { WelcomeEmailProps } from 'src/interfaces/notification.interface'; -import { ImmichButton } from './components/button.component'; -import ImmichLayout from './components/immich.layout'; export const WelcomeEmail = ({ baseUrl, displayName, username, password }: WelcomeEmailProps) => ( diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts index 66ed58c4f1..e75b3cd43e 100644 --- a/server/src/entities/album-user.entity.ts +++ b/server/src/entities/album-user.entity.ts @@ -1,12 +1,8 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AlbumUserRole } from 'src/enum'; import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; -export enum AlbumUserRole { - EDITOR = 'editor', - VIEWER = 'viewer', -} - @Entity('albums_shared_users_users') // Pre-existing indices from original album <--> user ManyToMany mapping @Index('IDX_427c350ad49bd3935a50baab73', ['album']) diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 39d5b72bf2..5aec5a0f47 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -2,6 +2,7 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetOrder } from 'src/enum'; import { Column, CreateDateColumn, @@ -15,12 +16,6 @@ import { UpdateDateColumn, } from 'typeorm'; -// ran into issues when importing the enum from `asset.dto.ts` -export enum AssetOrder { - ASC = 'asc', - DESC = 'desc', -} - @Entity('albums') export class AlbumEntity { @PrimaryGeneratedColumn('uuid') @@ -57,7 +52,7 @@ export class AlbumEntity { albumUsers!: AlbumUserEntity[]; @ManyToMany(() => AssetEntity, (asset) => asset.albums) - @JoinTable() + @JoinTable({ synchronize: false }) assets!: AssetEntity[]; @OneToMany(() => SharedLinkEntity, (link) => link.album) diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 18aaa83041..998ee4f8ef 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -1,4 +1,5 @@ import { UserEntity } from 'src/entities/user.entity'; +import { Permission } from 'src/enum'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; @Entity('api_keys') @@ -18,6 +19,9 @@ export class APIKeyEntity { @Column() userId!: string; + @Column({ array: true, type: 'varchar' }) + permissions!: Permission[]; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index c21aacfcd1..3a4e916cba 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -1,6 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; @Entity('asset_faces', { synchronize: false }) @@ -37,6 +38,9 @@ export class AssetFaceEntity { @Column({ default: 0, type: 'int' }) boundingBoxY2!: number; + @Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType }) + sourceType!: SourceType; + @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset!: AssetEntity; diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts new file mode 100644 index 0000000000..a8a6ddfee1 --- /dev/null +++ b/server/src/entities/asset-files.entity.ts @@ -0,0 +1,38 @@ +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetFileType } from 'src/enum'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +@Unique('UQ_assetId_type', ['assetId', 'type']) +@Entity('asset_files') +export class AssetFileEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index('IDX_asset_files_assetId') + @Column() + assetId!: string; + + @ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + asset?: AssetEntity; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column() + type!: AssetFileType; + + @Column() + path!: string; +} diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts index 44c0a04696..353055df43 100644 --- a/server/src/entities/asset-job-status.entity.ts +++ b/server/src/entities/asset-job-status.entity.ts @@ -18,4 +18,10 @@ export class AssetJobStatusEntity { @Column({ type: 'timestamptz', nullable: true }) duplicatesDetectedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + previewAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + thumbnailAt!: Date | null; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index ca486fb471..0b893134d0 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { AlbumEntity } from 'src/entities/album.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 { ExifEntity } from 'src/entities/exif.entity'; import { LibraryEntity } from 'src/entities/library.entity'; @@ -9,6 +10,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetStatus, AssetType } from 'src/enum'; import { Column, CreateDateColumn, @@ -68,14 +70,14 @@ export class AssetEntity { @Column() type!: AssetType; + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) + status!: AssetStatus; + @Column() originalPath!: string; - @Column({ type: 'varchar', nullable: true }) - previewPath!: string | null; - - @Column({ type: 'varchar', nullable: true, default: '' }) - thumbnailPath!: string | null; + @OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset) + files!: AssetFileEntity[]; @Column({ type: 'bytea', nullable: true }) thumbhash!: Buffer | null; @@ -175,10 +177,3 @@ export class AssetEntity { @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; } - -export enum AssetType { - IMAGE = 'IMAGE', - VIDEO = 'VIDEO', - AUDIO = 'AUDIO', - OTHER = 'OTHER', -} diff --git a/server/src/entities/audit.entity.ts b/server/src/entities/audit.entity.ts index be5e14891c..7f51e17585 100644 --- a/server/src/entities/audit.entity.ts +++ b/server/src/entities/audit.entity.ts @@ -1,16 +1,6 @@ +import { DatabaseAction, EntityType } from 'src/enum'; import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -export enum DatabaseAction { - CREATE = 'CREATE', - UPDATE = 'UPDATE', - DELETE = 'DELETE', -} - -export enum EntityType { - ASSET = 'ASSET', - ALBUM = 'ALBUM', -} - @Entity('audit') @Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt']) export class AuditEntity { diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index 3461faa685..c9c29d732a 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -95,6 +95,9 @@ export class ExifEntity { @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; + @Column({ type: 'integer', nullable: true }) + rating!: number | null; + /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 148e264095..7425ee67d8 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -3,6 +3,7 @@ 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'; @@ -24,6 +25,7 @@ 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, @@ -32,6 +34,7 @@ export const entities = [ APIKeyEntity, AssetEntity, AssetFaceEntity, + AssetFileEntity, AssetJobStatusEntity, AuditEntity, ExifEntity, @@ -52,4 +55,5 @@ export const entities = [ UserMetadataEntity, SessionEntity, LibraryEntity, + VersionHistoryEntity, ]; diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts index d7dcff4b80..c8121dd32e 100644 --- a/server/src/entities/memory.entity.ts +++ b/server/src/entities/memory.entity.ts @@ -1,5 +1,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { MemoryType } from 'src/enum'; import { Column, CreateDateColumn, @@ -12,11 +13,6 @@ import { UpdateDateColumn, } from 'typeorm'; -export enum MemoryType { - /** pictures taken on this day X years ago */ - ON_THIS_DAY = 'on_this_day', -} - export type OnThisDayData = { year: number }; export interface MemoryData { diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts index f3dad6b280..5cdef5d22e 100644 --- a/server/src/entities/move.entity.ts +++ b/server/src/entities/move.entity.ts @@ -1,3 +1,4 @@ +import { PathType } from 'src/enum'; import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm'; @Entity('move_history') @@ -21,21 +22,3 @@ export class MoveEntity { @Column({ type: 'varchar' }) newPath!: string; } - -export enum AssetPathType { - ORIGINAL = 'original', - PREVIEW = 'preview', - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded_video', - SIDECAR = 'sidecar', -} - -export enum PersonPathType { - FACE = 'face', -} - -export enum UserPathType { - PROFILE = 'profile', -} - -export type PathType = AssetPathType | PersonPathType | UserPathType; diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index f328192f7f..1fed44b301 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -1,6 +1,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { SharedLinkType } from 'src/enum'; import { Column, CreateDateColumn, @@ -62,13 +63,3 @@ export class SharedLinkEntity { @Column({ type: 'varchar', nullable: true }) albumId!: string | null; } - -export enum SharedLinkType { - ALBUM = 'ALBUM', - - /** - * Individual asset - * or group of assets that are not in an album - */ - INDIVIDUAL = 'INDIVIDUAL', -} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 72aca4c72b..0a03a55403 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,4 +1,5 @@ import { SystemConfig } from 'src/config'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_metadata') @@ -10,22 +11,15 @@ export class SystemMetadataEntity }; export interface SystemMetadata extends Record> { - [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; - [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; - [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; - [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index 93edcb0555..ebcc6853c9 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -1,45 +1,53 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + Tree, + TreeChildren, + TreeParent, + Unique, + UpdateDateColumn, +} from 'typeorm'; @Entity('tags') -@Unique('UQ_tag_name_userId', ['name', 'userId']) +@Unique(['userId', 'value']) +@Tree('closure-table') export class TagEntity { @PrimaryGeneratedColumn('uuid') id!: string; @Column() - type!: TagType; + value!: string; - @Column() - name!: string; + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; - @ManyToOne(() => UserEntity, (user) => user.tags) - user!: UserEntity; + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ type: 'varchar', nullable: true, default: null }) + color!: string | null; + + @Column({ nullable: true }) + parentId?: string; + + @TreeParent({ onDelete: 'CASCADE' }) + parent?: TagEntity; + + @TreeChildren() + children?: TagEntity[]; + + @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + user?: UserEntity; @Column() userId!: string; - @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true }) - renameTagId!: string | null; - - @ManyToMany(() => AssetEntity, (asset) => asset.tags) - assets!: AssetEntity[]; -} - -export enum TagType { - /** - * Tag that is detected by the ML model for object detection will use this type - */ - OBJECT = 'OBJECT', - - /** - * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type - */ - FACE = 'FACE', - - /** - * Tag that is created by the user will use this type - */ - CUSTOM = 'CUSTOM', + @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + assets?: AssetEntity[]; } diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index cbc889a5b9..c342cb71f8 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -1,4 +1,5 @@ import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { HumanReadableSize } from 'src/utils/bytes'; import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; @@ -17,23 +18,25 @@ export class UserMetadataEntity value!: UserMetadata[T]; } -export enum UserAvatarColor { - PRIMARY = 'primary', - PINK = 'pink', - RED = 'red', - YELLOW = 'yellow', - BLUE = 'blue', - GREEN = 'green', - PURPLE = 'purple', - ORANGE = 'orange', - GRAY = 'gray', - AMBER = 'amber', -} - export interface UserPreferences { + folders: { + enabled: boolean; + sidebarWeb: boolean; + }; memories: { enabled: boolean; }; + people: { + enabled: boolean; + sidebarWeb: boolean; + }; + ratings: { + enabled: boolean; + }; + tags: { + enabled: boolean; + sidebarWeb: boolean; + }; avatar: { color: UserAvatarColor; }; @@ -44,6 +47,7 @@ export interface UserPreferences { }; download: { archiveSize: number; + includeEmbeddedVideos: boolean; }; purchase: { showSupportBadge: boolean; @@ -58,9 +62,24 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences ); return { + folders: { + enabled: false, + sidebarWeb: false, + }, memories: { enabled: true, }, + people: { + enabled: true, + sidebarWeb: false, + }, + ratings: { + enabled: false, + }, + tags: { + enabled: false, + sidebarWeb: false, + }, avatar: { color: values[randomIndex], }, @@ -71,6 +90,7 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }, download: { archiveSize: HumanReadableSize.GiB * 4, + includeEmbeddedVideos: false, }, purchase: { showSupportBadge: true, @@ -79,11 +99,6 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences }; }; -export enum UserMetadataKey { - PREFERENCES = 'preferences', - LICENSE = 'license', -} - export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 6878292ab0..ea446be390 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -1,6 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; +import { UserStatus } from 'src/enum'; import { Column, CreateDateColumn, @@ -11,12 +12,6 @@ import { UpdateDateColumn, } from 'typeorm'; -export enum UserStatus { - ACTIVE = 'active', - REMOVING = 'removing', - DELETED = 'deleted', -} - @Entity('users') export class UserEntity { @PrimaryGeneratedColumn('uuid') @@ -72,4 +67,7 @@ export class UserEntity { @OneToMany(() => UserMetadataEntity, (metadata) => metadata.user) metadata!: UserMetadataEntity[]; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + profileChangedAt!: Date; } diff --git a/server/src/entities/version-history.entity.ts b/server/src/entities/version-history.entity.ts new file mode 100644 index 0000000000..edccd9aed6 --- /dev/null +++ b/server/src/entities/version-history.entity.ts @@ -0,0 +1,13 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('version_history') +export class VersionHistoryEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column() + version!: string; +} diff --git a/server/src/enum.ts b/server/src/enum.ts new file mode 100644 index 0000000000..c3b3d8341b --- /dev/null +++ b/server/src/enum.ts @@ -0,0 +1,374 @@ +export enum AuthType { + PASSWORD = 'password', + OAUTH = 'oauth', +} + +export enum ImmichCookie { + ACCESS_TOKEN = 'immich_access_token', + AUTH_TYPE = 'immich_auth_type', + IS_AUTHENTICATED = 'immich_is_authenticated', + SHARED_LINK_TOKEN = 'immich_shared_link_token', +} + +export enum ImmichHeader { + API_KEY = 'x-api-key', + USER_TOKEN = 'x-immich-user-token', + SESSION_TOKEN = 'x-immich-session-token', + SHARED_LINK_KEY = 'x-immich-share-key', + CHECKSUM = 'x-immich-checksum', + CID = 'x-immich-cid', +} + +export enum ImmichQuery { + SHARED_LINK_KEY = 'key', + API_KEY = 'apiKey', + SESSION_KEY = 'sessionKey', +} + +export enum AssetType { + IMAGE = 'IMAGE', + VIDEO = 'VIDEO', + AUDIO = 'AUDIO', + OTHER = 'OTHER', +} + +export enum AssetFileType { + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', +} + +export enum AlbumUserRole { + EDITOR = 'editor', + VIEWER = 'viewer', +} + +export enum AssetOrder { + ASC = 'asc', + DESC = 'desc', +} + +export enum DatabaseAction { + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', +} + +export enum EntityType { + ASSET = 'ASSET', + ALBUM = 'ALBUM', +} + +export enum MemoryType { + /** pictures taken on this day X years ago */ + ON_THIS_DAY = 'on_this_day', +} + +export enum Permission { + ALL = 'all', + + ACTIVITY_CREATE = 'activity.create', + ACTIVITY_READ = 'activity.read', + ACTIVITY_UPDATE = 'activity.update', + ACTIVITY_DELETE = 'activity.delete', + ACTIVITY_STATISTICS = 'activity.statistics', + + API_KEY_CREATE = 'apiKey.create', + API_KEY_READ = 'apiKey.read', + API_KEY_UPDATE = 'apiKey.update', + API_KEY_DELETE = 'apiKey.delete', + + // ASSET_CREATE = 'asset.create', + ASSET_READ = 'asset.read', + ASSET_UPDATE = 'asset.update', + ASSET_DELETE = 'asset.delete', + ASSET_SHARE = 'asset.share', + ASSET_VIEW = 'asset.view', + ASSET_DOWNLOAD = 'asset.download', + ASSET_UPLOAD = 'asset.upload', + + ALBUM_CREATE = 'album.create', + ALBUM_READ = 'album.read', + ALBUM_UPDATE = 'album.update', + ALBUM_DELETE = 'album.delete', + ALBUM_STATISTICS = 'album.statistics', + + ALBUM_ADD_ASSET = 'album.addAsset', + ALBUM_REMOVE_ASSET = 'album.removeAsset', + ALBUM_SHARE = 'album.share', + ALBUM_DOWNLOAD = 'album.download', + + AUTH_DEVICE_DELETE = 'authDevice.delete', + + ARCHIVE_READ = 'archive.read', + + FACE_CREATE = 'face.create', + FACE_READ = 'face.read', + FACE_UPDATE = 'face.update', + FACE_DELETE = 'face.delete', + + LIBRARY_CREATE = 'library.create', + LIBRARY_READ = 'library.read', + LIBRARY_UPDATE = 'library.update', + LIBRARY_DELETE = 'library.delete', + LIBRARY_STATISTICS = 'library.statistics', + + TIMELINE_READ = 'timeline.read', + TIMELINE_DOWNLOAD = 'timeline.download', + + MEMORY_CREATE = 'memory.create', + MEMORY_READ = 'memory.read', + MEMORY_UPDATE = 'memory.update', + MEMORY_DELETE = 'memory.delete', + + PARTNER_CREATE = 'partner.create', + PARTNER_READ = 'partner.read', + PARTNER_UPDATE = 'partner.update', + PARTNER_DELETE = 'partner.delete', + + PERSON_CREATE = 'person.create', + PERSON_READ = 'person.read', + PERSON_UPDATE = 'person.update', + PERSON_DELETE = 'person.delete', + PERSON_STATISTICS = 'person.statistics', + PERSON_MERGE = 'person.merge', + PERSON_REASSIGN = 'person.reassign', + + SESSION_READ = 'session.read', + SESSION_UPDATE = 'session.update', + SESSION_DELETE = 'session.delete', + + SHARED_LINK_CREATE = 'sharedLink.create', + SHARED_LINK_READ = 'sharedLink.read', + SHARED_LINK_UPDATE = 'sharedLink.update', + SHARED_LINK_DELETE = 'sharedLink.delete', + + STACK_CREATE = 'stack.create', + STACK_READ = 'stack.read', + STACK_UPDATE = 'stack.update', + STACK_DELETE = 'stack.delete', + + SYSTEM_CONFIG_READ = 'systemConfig.read', + SYSTEM_CONFIG_UPDATE = 'systemConfig.update', + + SYSTEM_METADATA_READ = 'systemMetadata.read', + SYSTEM_METADATA_UPDATE = 'systemMetadata.update', + + TAG_CREATE = 'tag.create', + TAG_READ = 'tag.read', + TAG_UPDATE = 'tag.update', + TAG_DELETE = 'tag.delete', + TAG_ASSET = 'tag.asset', + + ADMIN_USER_CREATE = 'admin.user.create', + ADMIN_USER_READ = 'admin.user.read', + ADMIN_USER_UPDATE = 'admin.user.update', + ADMIN_USER_DELETE = 'admin.user.delete', +} + +export enum SharedLinkType { + ALBUM = 'ALBUM', + + /** + * Individual asset + * or group of assets that are not in an album + */ + INDIVIDUAL = 'INDIVIDUAL', +} + +export enum StorageFolder { + ENCODED_VIDEO = 'encoded-video', + LIBRARY = 'library', + UPLOAD = 'upload', + PROFILE = 'profile', + THUMBNAILS = 'thumbs', + BACKUPS = 'backups', +} + +export enum SystemMetadataKey { + REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', + FACIAL_RECOGNITION_STATE = 'facial-recognition-state', + ADMIN_ONBOARDING = 'admin-onboarding', + SYSTEM_CONFIG = 'system-config', + SYSTEM_FLAGS = 'system-flags', + VERSION_CHECK_STATE = 'version-check-state', + LICENSE = 'license', +} + +export enum UserMetadataKey { + PREFERENCES = 'preferences', + LICENSE = 'license', +} + +export enum UserAvatarColor { + PRIMARY = 'primary', + PINK = 'pink', + RED = 'red', + YELLOW = 'yellow', + BLUE = 'blue', + GREEN = 'green', + PURPLE = 'purple', + ORANGE = 'orange', + GRAY = 'gray', + AMBER = 'amber', +} + +export enum UserStatus { + ACTIVE = 'active', + REMOVING = 'removing', + DELETED = 'deleted', +} + +export enum AssetStatus { + ACTIVE = 'active', + TRASHED = 'trashed', + DELETED = 'deleted', +} + +export enum SourceType { + MACHINE_LEARNING = 'machine-learning', + EXIF = 'exif', +} + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} + +export enum AssetPathType { + ORIGINAL = 'original', + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded_video', + SIDECAR = 'sidecar', +} + +export enum PersonPathType { + FACE = 'face', +} + +export enum UserPathType { + PROFILE = 'profile', +} + +export type PathType = AssetPathType | PersonPathType | UserPathType; + +export enum TranscodePolicy { + ALL = 'all', + OPTIMAL = 'optimal', + BITRATE = 'bitrate', + REQUIRED = 'required', + DISABLED = 'disabled', +} + +export enum TranscodeTarget { + NONE, + AUDIO, + VIDEO, + ALL, +} + +export enum VideoCodec { + H264 = 'h264', + HEVC = 'hevc', + VP9 = 'vp9', + AV1 = 'av1', +} + +export enum AudioCodec { + MP3 = 'mp3', + AAC = 'aac', + LIBOPUS = 'libopus', + PCMS16LE = 'pcm_s16le', +} + +export enum VideoContainer { + MOV = 'mov', + MP4 = 'mp4', + OGG = 'ogg', + WEBM = 'webm', +} + +export enum TranscodeHWAccel { + NVENC = 'nvenc', + QSV = 'qsv', + VAAPI = 'vaapi', + RKMPP = 'rkmpp', + DISABLED = 'disabled', +} + +export enum ToneMapping { + HABLE = 'hable', + MOBIUS = 'mobius', + REINHARD = 'reinhard', + DISABLED = 'disabled', +} + +export enum CQMode { + AUTO = 'auto', + CQP = 'cqp', + ICQ = 'icq', +} + +export enum Colorspace { + SRGB = 'srgb', + P3 = 'p3', +} + +export enum ImageFormat { + JPEG = 'jpeg', + WEBP = 'webp', +} + +export enum LogLevel { + VERBOSE = 'verbose', + DEBUG = 'debug', + LOG = 'log', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export enum MetadataKey { + AUTH_ROUTE = 'auth_route', + ADMIN_ROUTE = 'admin_route', + SHARED_ROUTE = 'shared_route', + API_KEY_SECURITY = 'api_key', + EVENT_CONFIG = 'event_config', + TELEMETRY_ENABLED = 'telemetry_enabled', +} + +export enum RouteKey { + ASSET = 'assets', + USER = 'users', +} + +export enum CacheControl { + PRIVATE_WITH_CACHE = 'private_with_cache', + PRIVATE_WITHOUT_CACHE = 'private_without_cache', + NONE = 'none', +} + +export enum PaginationMode { + LIMIT_OFFSET = 'limit-offset', + SKIP_TAKE = 'skip-take', +} + +export enum ImmichEnvironment { + DEVELOPMENT = 'development', + TESTING = 'testing', + PRODUCTION = 'production', +} + +export enum ImmichWorker { + API = 'api', + MICROSERVICES = 'microservices', +} + +export enum ImmichTelemetry { + HOST = 'host', + API = 'api', + IO = 'io', + REPO = 'repo', + JOB = 'job', +} diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 6b408c263e..d8d7b4e807 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -1,4 +1,4 @@ -import { AlbumUserRole } from 'src/entities/album-user.entity'; +import { AlbumUserRole } from 'src/enum'; export const IAccessRepository = 'IAccessRepository'; @@ -42,4 +42,12 @@ export interface IAccessRepository { partner: { checkUpdateAccess(userId: string, partnerIds: Set): Promise>; }; + + stack: { + checkOwnerAccess(userId: string, stackIds: Set): Promise>; + }; + + tag: { + checkOwnerAccess(userId: string, tagIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 091442ff05..24c64bdc9d 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -16,18 +16,15 @@ export interface AlbumInfoOptions { export interface IAlbumRepository extends IBulkAsset { getById(id: string, options: AlbumInfoOptions): Promise; - getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; removeAsset(assetId: string): Promise; getMetadataForIds(ids: string[]): Promise; - getInvalidThumbnail(): 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; - getAll(): Promise; create(album: Partial): Promise; update(album: Partial): Promise; delete(id: string): Promise; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 37115a6e3a..37d3326a8a 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,7 +1,7 @@ -import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -16,6 +16,7 @@ export interface AssetStatsOptions { export interface LivePhotoSearchOptions { ownerId: string; + libraryId?: string | null; livePhotoCID: string; otherAssetId: string; type: AssetType; @@ -35,7 +36,6 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', - IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -49,10 +49,12 @@ export interface AssetBuilderOptions { isTrashed?: boolean; isDuplicate?: boolean; albumId?: string; + tagId?: string; personId?: string; userIds?: string[]; withStacked?: boolean; exifInfo?: boolean; + status?: AssetStatus; assetType?: AssetType; } @@ -139,6 +141,12 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -164,11 +172,8 @@ export interface IAssetRepository { order?: FindOptionsOrder, ): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; - getRandom(userId: string, count: number): Promise; - getFirstAssetForAlbumId(albumId: string): Promise; + getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; @@ -178,8 +183,6 @@ export interface IAssetRepository { updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; - softDeleteAll(ids: string[]): Promise; - restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; @@ -191,4 +194,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/audit.interface.ts b/server/src/interfaces/audit.interface.ts index b023d00d56..0b9f19d8db 100644 --- a/server/src/interfaces/audit.interface.ts +++ b/server/src/interfaces/audit.interface.ts @@ -1,4 +1,4 @@ -import { DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; export const IAuditRepository = 'IAuditRepository'; diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts new file mode 100644 index 0000000000..ec5397cc2c --- /dev/null +++ b/server/src/interfaces/config.interface.ts @@ -0,0 +1,96 @@ +import { RegisterQueueOptions } from '@nestjs/bullmq'; +import { QueueOptions } from 'bullmq'; +import { RedisOptions } from 'ioredis'; +import { ClsModuleOptions } from 'nestjs-cls'; +import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; +import { ImmichEnvironment, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; +import { DatabaseConnectionParams, VectorExtension } from 'src/interfaces/database.interface'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; + +export const IConfigRepository = 'IConfigRepository'; + +export interface EnvData { + host?: string; + port: number; + environment: ImmichEnvironment; + configFile?: string; + logLevel?: LogLevel; + + buildMetadata: { + build?: string; + buildUrl?: string; + buildImage?: string; + buildImageUrl?: string; + repository?: string; + repositoryUrl?: string; + sourceRef?: string; + sourceCommit?: string; + sourceUrl?: string; + thirdPartySourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySupportUrl?: string; + }; + + bull: { + config: QueueOptions; + queues: RegisterQueueOptions[]; + }; + + cls: { + config: ClsModuleOptions; + }; + + database: { + config: PostgresConnectionOptions & DatabaseConnectionParams; + skipMigrations: boolean; + vectorExtension: VectorExtension; + }; + + licensePublicKey: { + client: string; + server: string; + }; + + network: { + trustedProxies: string[]; + }; + + otel: OpenTelemetryModuleOptions; + + resourcePaths: { + lockFile: string; + geodata: { + dateFile: string; + admin1: string; + admin2: string; + cities500: string; + naturalEarthCountriesPath: string; + }; + web: { + root: string; + indexHtml: string; + }; + }; + + redis: RedisOptions; + + telemetry: { + apiPort: number; + microservicesPort: number; + metrics: Set; + }; + + storage: { + ignoreMountCheckErrors: boolean; + }; + + workers: ImmichWorker[]; + + noColor: boolean; + nodeVersion?: string; +} + +export interface IConfigRepository { + getEnv(): EnvData; +} diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index f78f6388fb..6a10a92f31 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -7,6 +7,22 @@ export enum DatabaseExtension { 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', @@ -15,10 +31,13 @@ export enum VectorIndex { export enum DatabaseLock { GeodataImport = 100, Migrations = 200, + SystemFileMounts = 300, StorageTemplateMigration = 420, + VersionHistory = 500, CLIPDimSize = 512, - LibraryWatch = 1337, + Library = 1337, GetSystemConfig = 69, + BackupDatabase = 42, } export const EXTENSION_NAMES: Record = { @@ -28,6 +47,11 @@ export const EXTENSION_NAMES: Record = { vectors: 'pgvecto.rs', } as const; +export interface ExtensionVersion { + availableVersion: string | null; + installedVersion: string | null; +} + export interface VectorUpdateResult { restartRequired: boolean; } @@ -35,11 +59,12 @@ export interface VectorUpdateResult { export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { - getExtensionVersion(extensionName: string): Promise; - getAvailableExtensionVersion(extension: DatabaseExtension): Promise; + reconnect(): Promise; + getExtensionVersion(extension: DatabaseExtension): Promise; + getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; + getPostgresVersionRange(): string; createExtension(extension: DatabaseExtension): Promise; - updateExtension(extension: DatabaseExtension, version?: string): Promise; updateVectorExtension(extension: VectorExtension, version?: string): Promise; reindex(index: VectorIndex): Promise; shouldReindex(name: VectorIndex): Promise; diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 828531fdf3..8b59457914 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -1,99 +1,105 @@ +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 { ImmichWorker } from 'src/enum'; export const IEventRepository = 'IEventRepository'; -export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig }; -export type AlbumUpdateEvent = { - id: string; - /** user id */ - updatedBy: string; -}; -export type AlbumInviteEvent = { id: string; userId: string }; -export type UserSignupEvent = { notify: boolean; id: string; tempPassword?: string }; - -type MaybePromise = Promise | T; -type Handler = (data: T) => MaybePromise; - -const noop = () => {}; -const dummyHandlers = { +type EventMap = { // app events - onBootstrapEvent: noop as Handler<'api' | 'microservices'>, - onShutdownEvent: noop as () => MaybePromise, + 'app.bootstrap': [ImmichWorker]; + 'app.shutdown': [ImmichWorker]; // config events - onConfigUpdateEvent: noop as Handler, - onConfigValidateEvent: noop as Handler, + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; + 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - onAlbumUpdateEvent: noop as Handler, - onAlbumInviteEvent: noop as Handler, + '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 }]; + + // 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 - onUserSignupEvent: noop as Handler, + 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EventHandlers = typeof dummyHandlers; -export type EmitEvent = keyof EventHandlers; -export type EmitEventHandler = (...args: Parameters) => MaybePromise; -export const events = Object.keys(dummyHandlers) as EmitEvent[]; -export type OnEvents = Partial; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; -export enum ClientEvent { - UPLOAD_SUCCESS = 'on_upload_success', - USER_DELETE = 'on_user_delete', - ASSET_DELETE = 'on_asset_delete', - ASSET_TRASH = 'on_asset_trash', - ASSET_UPDATE = 'on_asset_update', - ASSET_HIDDEN = 'on_asset_hidden', - ASSET_RESTORE = 'on_asset_restore', - ASSET_STACK_UPDATE = 'on_asset_stack_update', - PERSON_THUMBNAIL = 'on_person_thumbnail', - SERVER_VERSION = 'on_server_version', - CONFIG_UPDATE = 'on_config_update', - NEW_RELEASE = 'on_new_release', -} +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 { - [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; - [ClientEvent.USER_DELETE]: string; - [ClientEvent.ASSET_DELETE]: string; - [ClientEvent.ASSET_TRASH]: string[]; - [ClientEvent.ASSET_UPDATE]: AssetResponseDto; - [ClientEvent.ASSET_HIDDEN]: string; - [ClientEvent.ASSET_RESTORE]: string[]; - [ClientEvent.ASSET_STACK_UPDATE]: string[]; - [ClientEvent.PERSON_THUMBNAIL]: string; - [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; - [ClientEvent.CONFIG_UPDATE]: Record; - [ClientEvent.NEW_RELEASE]: ReleaseNotification; + 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 enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', -} - -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitEventHandler): void; - emit(event: T, ...args: Parameters>): Promise; + setup(options: { services: ClassConstructor[] }): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user */ - clientSend(event: E, userId: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; /** * Send to all connected clients */ - clientBroadcast(event: E, data: ClientEventMap[E]): void; + clientBroadcast(event: E, ...data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 0fd35167af..31945f97ec 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -15,11 +15,15 @@ export enum QueueName { 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.STORAGE_TEMPLATE_MIGRATION + | QueueName.FACIAL_RECOGNITION + | QueueName.DUPLICATE_DETECTION + | QueueName.BACKUP_DATABASE >; export enum JobCommand { @@ -31,15 +35,16 @@ export enum JobCommand { } 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_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -60,6 +65,9 @@ export enum JobName { 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', @@ -73,11 +81,12 @@ export enum JobName { FACIAL_RECOGNITION = 'facial-recognition', // library management - LIBRARY_SCAN = 'library-refresh', - LIBRARY_SCAN_ASSET = 'library-refresh-asset', - LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', + 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_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup @@ -89,6 +98,8 @@ export enum JobName { 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', @@ -110,14 +121,21 @@ export enum JobName { } 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 { @@ -129,9 +147,9 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } -export interface ILibraryRefreshJob extends IEntityJob { - refreshModifiedFiles: boolean; - refreshAllFiles: boolean; +export interface ILibraryAssetJob extends IEntityJob { + importPaths: string[]; + exclusionPatterns: string[]; } export interface IBulkEntityJob extends IBaseJob { @@ -147,6 +165,8 @@ export interface ISidecarWriteJob extends IEntityJob { dateTimeOriginal?: string; latitude?: number; longitude?: number; + rating?: number; + tags?: true; } export interface IDeferrableJob extends IEntityJob { @@ -173,8 +193,8 @@ export interface INotifyAlbumInviteJob extends IEntityJob { recipientId: string; } -export interface INotifyAlbumUpdateJob extends IEntityJob { - senderId: string; +export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { + recipientIds: string[]; } export interface JobCounts { @@ -196,15 +216,16 @@ export enum QueueCleanType { } 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_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } @@ -240,6 +261,7 @@ export type JobItem = // 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 } @@ -252,17 +274,21 @@ export type JobItem = | { 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_SCAN_ASSET; data: ILibraryFileJob } - | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } - | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { 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: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification @@ -289,7 +315,6 @@ export interface IJobRepository { addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void; addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void; updateCronJob(name: string, expression?: string, start?: boolean): void; - deleteCronJob(name: string): void; setConcurrency(queueName: QueueName, concurrency: number): void; queue(item: JobItem): Promise; queueAll(items: JobItem[]): Promise; @@ -300,4 +325,5 @@ export interface IJobRepository { 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 index 6468977df4..d8f1a13031 100644 --- a/server/src/interfaces/library.interface.ts +++ b/server/src/interfaces/library.interface.ts @@ -12,5 +12,4 @@ export interface ILibraryRepository { softDelete(id: string): Promise; update(library: Partial): Promise; getStatistics(id: string): Promise; - getAssetIds(id: string, withDeleted?: boolean): Promise; } diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index f0afdce2a5..92984bf8e1 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -1,11 +1,12 @@ -import { LogLevel } from 'src/config'; +import { ImmichWorker, LogLevel } from 'src/enum'; export const ILoggerRepository = 'ILoggerRepository'; export interface ILoggerRepository { - setAppName(name: string): void; + setAppName(name: ImmichWorker): void; setContext(message: string): void; - setLogLevel(level: LogLevel): void; + setLogLevel(level: LogLevel | false): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index dce75ffd25..0a04840a96 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -26,7 +26,6 @@ export interface MapMarker extends ReverseGeocodeResult { export interface IMapRepository { init(): Promise; - reverseGeocode(point: GeoPoint): Promise; + reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; - fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d06..2bc8ccde36 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,5 +1,5 @@ import { Writable } from 'node:stream'; -import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; +import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; export const IMediaRepository = 'IMediaRepository'; @@ -10,13 +10,44 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOptions { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { + colorspace: string; crop?: CropOptions; processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; + crop?: CropOptions; + preview?: ImageOptions; + processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -62,6 +93,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -71,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -79,14 +119,21 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 1ccd704b59..574420e27a 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,7 +7,19 @@ export interface ExifDuration { Scale?: number; } -export interface ImmichTags extends Omit { +type StringOrNumber = string | number; + +type TagsWithWrongTypes = + | 'FocalLength' + | 'Duration' + | 'Description' + | 'ImageDescription' + | 'RegionInfo' + | 'TagsList' + | 'Keywords' + | 'HierarchicalSubject' + | 'ISO'; +export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; MotionPhotoVersion?: number; @@ -19,16 +31,41 @@ export interface ImmichTags extends Omit { EmbeddedVideoType?: string; EmbeddedVideoFile?: BinaryField; MotionPhotoVideo?: BinaryField; + TagsList?: StringOrNumber[]; + HierarchicalSubject?: StringOrNumber[]; + Keywords?: StringOrNumber | StringOrNumber[]; + ISO?: number | number[]; + + // Type is wrong, can also be number. + Description?: StringOrNumber; + ImageDescription?: StringOrNumber; + + // Extended properties for image regions, such as faces + RegionInfo?: { + AppliedToDimensions: { + W: number; + H: number; + Unit: string; + }; + RegionList: { + Area: { + // (X,Y) // center of the rectangle + X: number; + Y: number; + W: number; + H: number; + Unit: string; + }; + Rotation?: number; + Type?: string; + Name?: string; + }[]; + }; } export interface IMetadataRepository { teardown(): Promise; - readTags(path: string): Promise; + readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; - getCountries(userId: string): Promise; - getStates(userId: string, country?: string): Promise; - getCities(userId: string, country?: string, state?: string): Promise; - getCameraMakes(userId: string, model?: string): Promise; - getCameraModels(userId: string, make?: string): Promise; } diff --git a/server/src/interfaces/move.interface.ts b/server/src/interfaces/move.interface.ts index c9d39e78cf..0e79cfcadc 100644 --- a/server/src/interfaces/move.interface.ts +++ b/server/src/interfaces/move.interface.ts @@ -1,4 +1,5 @@ -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; export const IMoveRepository = 'IMoveRepository'; diff --git a/server/src/interfaces/notification.interface.ts b/server/src/interfaces/notification.interface.ts index c0ba4e209d..ec0ecc534b 100644 --- a/server/src/interfaces/notification.interface.ts +++ b/server/src/interfaces/notification.interface.ts @@ -90,7 +90,7 @@ export type SendEmailResponse = { }; export interface INotificationRepository { - renderEmail(request: EmailRenderRequest): { html: string; text: string }; + renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }>; sendEmail(options: SendEmailOptions): Promise; verifySmtp(options: SmtpOptions): Promise; } diff --git a/server/src/interfaces/oauth.interface.ts b/server/src/interfaces/oauth.interface.ts new file mode 100644 index 0000000000..5e629726a0 --- /dev/null +++ b/server/src/interfaces/oauth.interface.ts @@ -0,0 +1,22 @@ +import { UserinfoResponse } from 'openid-client'; + +export const IOAuthRepository = 'IOAuthRepository'; + +export type OAuthConfig = { + clientId: string; + clientSecret: string; + issuerUrl: string; + mobileOverrideEnabled: boolean; + mobileRedirectUri: string; + profileSigningAlgorithm: string; + scope: string; + signingAlgorithm: string; +}; +export type OAuthProfile = UserinfoResponse; + +export interface IOAuthRepository { + init(): void; + authorize(config: OAuthConfig, redirectUrl: string): Promise; + getLogoutEndpoint(config: OAuthConfig): Promise; + getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise; +} diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 358310a5cb..b3e2c0990e 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -15,6 +16,11 @@ export interface PersonNameSearchOptions { withHidden?: boolean; } +export interface PersonNameResponse { + id: string; + name: string; +} + export interface AssetFaceId { assetId: string; personId: string; @@ -35,20 +41,29 @@ export interface PeopleStatistics { hidden: number; } +export interface DeleteFacesOptions { + sourceType: SourceType; +} + +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; 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; - getAssets(personId: string): Promise; - - create(entity: Partial): Promise; - createFaces(entities: Partial[]): Promise; + create(person: Partial): Promise; + createAll(people: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; - deleteAll(): Promise; - deleteAllFaces(): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; + refreshFaces( + facesToAdd: Partial[], + faceIdsToRemove: string[], + embeddingsToAdd?: FaceSearchEntity[], + ): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; getFaceByIdWithAssets( @@ -63,6 +78,8 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; - update(entity: Partial): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; + update(person: Partial): Promise; + updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/interfaces/process.interface.ts b/server/src/interfaces/process.interface.ts new file mode 100644 index 0000000000..14a8c1ff33 --- /dev/null +++ b/server/src/interfaces/process.interface.ts @@ -0,0 +1,25 @@ +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 index c84b56c62e..63d74a35fb 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +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'; @@ -60,6 +61,7 @@ export interface SearchStatusOptions { isVisible?: boolean; isNotInAlbum?: boolean; type?: AssetType; + status?: AssetStatus; withArchived?: boolean; withDeleted?: boolean; } @@ -95,12 +97,12 @@ export interface SearchPathOptions { } export interface SearchExifOptions { - city?: string; - country?: string; - lensModel?: string; - make?: string; - model?: string; - state?: string; + city?: string | null; + country?: string | null; + lensModel?: string | null; + make?: string | null; + model?: string | null; + state?: string | null; } export interface SearchEmbeddingOptions { @@ -170,13 +172,20 @@ export interface AssetDuplicateResult { } export interface ISearchRepository { - init(modelName: string): Promise; 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: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; deleteAllSearchEmbeddings(): Promise; + getDimensionSize(): Promise; + setDimensionSize(dimSize: number): Promise; + getCountries(userIds: string[]): Promise>; + getStates(userIds: string[], country?: string): Promise>; + getCities(userIds: string[], country?: string, state?: string): Promise>; + getCameraMakes(userIds: string[], model?: string): Promise>; + getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/interfaces/stack.interface.ts b/server/src/interfaces/stack.interface.ts index 0e6baf0a34..378f63fd95 100644 --- a/server/src/interfaces/stack.interface.ts +++ b/server/src/interfaces/stack.interface.ts @@ -2,9 +2,16 @@ import { StackEntity } from 'src/entities/stack.entity'; export const IStackRepository = 'IStackRepository'; +export interface StackSearch { + ownerId: string; + primaryAssetId?: string; +} + export interface IStackRepository { - create(stack: Partial & { ownerId: string }): Promise; + search(query: StackSearch): Promise; + create(stack: { ownerId: string; assetIds: string[] }): Promise; update(stack: Pick & Partial): 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 index 1bd49a3f20..b304d94fef 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -1,8 +1,8 @@ import { WatchOptions } from 'chokidar'; import { Stats } from 'node:fs'; import { FileReadOptions } from 'node:fs/promises'; -import { Readable } from 'node:stream'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { Readable, Writable } from 'node:stream'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; export interface ImmichReadStream { stream: Readable; @@ -35,7 +35,11 @@ export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; - writeFile(filepath: string, buffer: Buffer): 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; @@ -44,8 +48,8 @@ export interface IStorageRepository { checkDiskUsage(folder: string): Promise; readdir(folder: string): Promise; stat(filepath: string): Promise; - crawl(crawlOptions: CrawlOptionsDto): Promise; - walk(crawlOptions: CrawlOptionsDto): AsyncGenerator; + 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; diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index 8071461dfc..16a34d6ac4 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -1,17 +1,21 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { IBulkAsset } from 'src/utils/asset.util'; export const ITagRepository = 'ITagRepository'; -export interface ITagRepository { - getById(userId: string, tagId: string): Promise; +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; - update(tag: Partial): Promise; - remove(tag: TagEntity): Promise; - hasName(userId: string, name: string): Promise; - hasAsset(userId: string, tagId: string, assetId: string): Promise; - getAssets(userId: string, tagId: string): Promise; - addAssets(userId: string, tagId: string, assetIds: string[]): Promise; - removeAssets(userId: string, tagId: string, assetIds: string[]): 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/metric.interface.ts b/server/src/interfaces/telemetry.interface.ts similarity index 71% rename from server/src/interfaces/metric.interface.ts rename to server/src/interfaces/telemetry.interface.ts index a87a849833..688e52c21e 100644 --- a/server/src/interfaces/metric.interface.ts +++ b/server/src/interfaces/telemetry.interface.ts @@ -1,6 +1,7 @@ import { MetricOptions } from '@opentelemetry/api'; +import { ClassConstructor } from 'class-transformer'; -export const IMetricRepository = 'IMetricRepository'; +export const ITelemetryRepository = 'ITelemetryRepository'; export interface MetricGroupOptions { enabled: boolean; @@ -13,7 +14,8 @@ export interface IMetricGroupRepository { configure(options: MetricGroupOptions): this; } -export interface IMetricRepository { +export interface ITelemetryRepository { + setup(options: { repositories: ClassConstructor[] }): void; api: IMetricGroupRepository; host: IMetricGroupRepository; jobs: IMetricGroupRepository; diff --git a/server/src/interfaces/trash.interface.ts b/server/src/interfaces/trash.interface.ts new file mode 100644 index 0000000000..96c2322d8a --- /dev/null +++ b/server/src/interfaces/trash.interface.ts @@ -0,0 +1,10 @@ +import { Paginated, PaginationOptions } from 'src/utils/pagination'; + +export const ITrashRepository = 'ITrashRepository'; + +export interface ITrashRepository { + empty(userId: string): Promise; + restore(userId: string): Promise; + restoreAll(assetIds: string[]): Promise; + getDeletedIds(pagination: PaginationOptions): Paginated; +} diff --git a/server/src/interfaces/version-history.interface.ts b/server/src/interfaces/version-history.interface.ts new file mode 100644 index 0000000000..6733706220 --- /dev/null +++ b/server/src/interfaces/version-history.interface.ts @@ -0,0 +1,9 @@ +import { VersionHistoryEntity } from 'src/entities/version-history.entity'; + +export const IVersionHistoryRepository = 'IVersionHistoryRepository'; + +export interface IVersionHistoryRepository { + create(version: Omit): Promise; + getAll(): Promise; + getLatest(): Promise; +} diff --git a/server/src/interfaces/view.interface.ts b/server/src/interfaces/view.interface.ts new file mode 100644 index 0000000000..f819160002 --- /dev/null +++ b/server/src/interfaces/view.interface.ts @@ -0,0 +1,8 @@ +import { AssetEntity } from 'src/entities/asset.entity'; + +export const IViewRepository = 'IViewRepository'; + +export interface IViewRepository { + getAssetsByOriginalPath(userId: string, partialPath: string): Promise; + getUniqueOriginalPaths(userId: string): Promise; +} diff --git a/server/src/main.ts b/server/src/main.ts index 7839bafd2f..3097eee69b 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,52 +1,74 @@ import { CommandFactory } from 'nest-commander'; -import { fork } from 'node:child_process'; +import { ChildProcess, fork } from 'node:child_process'; import { Worker } from 'node:worker_threads'; import { ImmichAdminModule } from 'src/app.module'; -import { LogLevel } from 'src/config'; -import { getWorkers } from 'src/utils/workers'; -const immichApp = process.argv[2] || process.env.IMMICH_APP; +import { ImmichWorker, LogLevel } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; -if (process.argv[2] === immichApp) { +const immichApp = process.argv[2]; +if (immichApp) { process.argv.splice(2, 1); } -async function bootstrapImmichAdmin() { - process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; - await CommandFactory.run(ImmichAdminModule); -} +let apiProcess: ChildProcess | undefined; -function bootstrapWorker(name: string) { - console.log(`Starting ${name} worker`); - const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`); - worker.on('exit', (exitCode) => { - if (exitCode !== 0) { - console.error(`${name} worker exited with code ${exitCode}`); - process.exit(exitCode); +const onError = (name: string, error: Error) => { + console.error(`${name} worker error: ${error}`); +}; + +const onExit = (name: string, exitCode: number | null) => { + if (exitCode !== 0) { + console.error(`${name} worker exited with code ${exitCode}`); + + if (apiProcess && name !== ImmichWorker.API) { + console.error('Killing api process'); + apiProcess.kill('SIGTERM'); + apiProcess = undefined; } - }); + } + + process.exit(exitCode); +}; + +function bootstrapWorker(name: ImmichWorker) { + console.log(`Starting ${name} worker`); + + let worker: Worker | ChildProcess; + if (name === ImmichWorker.API) { + worker = fork(`./dist/workers/${name}.js`, [], { + execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)), + }); + apiProcess = worker; + } else { + worker = new Worker(`./dist/workers/${name}.js`); + } + + worker.on('error', (error) => onError(name, error)); + worker.on('exit', (exitCode) => onExit(name, exitCode)); } function bootstrap() { - switch (immichApp) { - case 'immich-admin': { - process.title = 'immich_admin_cli'; - return bootstrapImmichAdmin(); - } - case 'immich': { - if (!process.env.IMMICH_WORKERS_INCLUDE) { - process.env.IMMICH_WORKERS_INCLUDE = 'api'; - } - break; - } - case 'microservices': { - if (!process.env.IMMICH_WORKERS_INCLUDE) { - process.env.IMMICH_WORKERS_INCLUDE = 'microservices'; - } - break; - } + if (immichApp === 'immich-admin') { + process.title = 'immich_admin_cli'; + process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; + return CommandFactory.run(ImmichAdminModule); } + + if (immichApp === 'immich' || immichApp === 'microservices') { + console.error( + `Using "start.sh ${immichApp}" has been deprecated. See https://github.com/immich-app/immich/releases/tag/v1.118.0 for more information.`, + ); + process.exit(1); + } + + if (immichApp) { + console.error(`Unknown command: "${immichApp}"`); + process.exit(1); + } + process.title = 'immich'; - for (const worker of getWorkers()) { + const { workers } = new ConfigRepository().getEnv(); + for (const worker of workers) { bootstrapWorker(worker); } } diff --git a/server/src/middleware/asset-upload.interceptor.ts b/server/src/middleware/asset-upload.interceptor.ts index 0f38c34259..bc403ee562 100644 --- a/server/src/middleware/asset-upload.interceptor.ts +++ b/server/src/middleware/asset-upload.interceptor.ts @@ -2,7 +2,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes import { Response } from 'express'; import { of } from 'rxjs'; import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; -import { ImmichHeader } from 'src/dtos/auth.dto'; +import { ImmichHeader } from 'src/enum'; import { AuthenticatedRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { fromMaybeArray } from 'src/utils/request'; diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index bac25d80ed..2eaf411475 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -10,29 +10,22 @@ import { import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; -import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ImmichQuery, MetadataKey, Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { UAParser } from 'ua-parser-js'; -export enum Metadata { - AUTH_ROUTE = 'auth_route', - ADMIN_ROUTE = 'admin_route', - SHARED_ROUTE = 'shared_route', - API_KEY_SECURITY = 'api_key', - EVENT_HANDLER_OPTIONS = 'event_handler_options', -} - type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; -type AuthenticatedOptions = AdminRoute | SharedLinkRoute; +type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => { const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(Metadata.API_KEY_SECURITY), - SetMetadata(Metadata.AUTH_ROUTE, options || {}), + ApiSecurity(MetadataKey.API_KEY_SECURITY), + SetMetadata(MetadataKey.AUTH_ROUTE, options || {}), ]; if ((options as SharedLinkRoute)?.sharedLink) { @@ -84,25 +77,23 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets); + const options = this.reflector.getAllAndOverride(MetadataKey.AUTH_ROUTE, targets); if (!options) { return true; } + const { + admin: adminRoute, + sharedLink: sharedLinkRoute, + permission, + } = { sharedLink: false, admin: false, ...options }; const request = context.switchToHttp().getRequest(); - const authDto = await this.authService.validate(request.headers, request.query as Record); - if (authDto.sharedLink && !(options as SharedLinkRoute).sharedLink) { - this.logger.warn(`Denied access to non-shared route: ${request.path}`); - return false; - } - - if (!authDto.user.isAdmin && (options as AdminRoute).admin) { - this.logger.warn(`Denied access to admin only route: ${request.path}`); - return false; - } - - request.user = authDto; + request.user = await this.authService.authenticate({ + headers: request.headers, + queryParams: request.query as Record, + metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, + }); return true; } diff --git a/server/src/middleware/error.interceptor.ts b/server/src/middleware/error.interceptor.ts index a0c333e4b2..5d93b40dc2 100644 --- a/server/src/middleware/error.interceptor.ts +++ b/server/src/middleware/error.interceptor.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; import { routeToErrorMessage } from 'src/utils/misc'; @Injectable() @@ -25,9 +26,10 @@ export class ErrorInterceptor implements NestInterceptor { return error; } - const errorMessage = routeToErrorMessage(context.getHandler().name); - this.logger.error(errorMessage, error, error?.errors, error?.stack); - return new InternalServerErrorException(errorMessage); + logGlobalError(this.logger, error); + + const message = routeToErrorMessage(context.getHandler().name); + return new InternalServerErrorException(message); }), ), ); diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6ec8b401ef..075a7f5046 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -7,6 +7,7 @@ import multer, { StorageEngine, diskStorage } from 'multer'; import { createHash, randomUUID } from 'node:crypto'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; @@ -28,11 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -export enum Route { - ASSET = 'assets', - USER = 'users', -} - export interface ImmichFile extends Express.Multer.File { /** sha1 hash of file */ uuid: string; @@ -115,7 +111,7 @@ export class FileUploadInterceptor implements NestInterceptor { const context_ = context.switchToHttp(); const route = this.reflect.get(PATH_METADATA, context.getClass()); - const handler: RequestHandler | null = this.getHandler(route as Route); + const handler: RequestHandler | null = this.getHandler(route as RouteKey); if (handler) { await new Promise((resolve, reject) => { const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve()); @@ -176,13 +172,13 @@ export class FileUploadInterceptor implements NestInterceptor { return false; } - private getHandler(route: Route) { + private getHandler(route: RouteKey) { switch (route) { - case Route.ASSET: { + case RouteKey.ASSET: { return this.handlers.assetUpload; } - case Route.USER: { + case RouteKey.USER: { return this.handlers.userProfile; } diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts new file mode 100644 index 0000000000..6200363e86 --- /dev/null +++ b/server/src/middleware/global-exception.filter.ts @@ -0,0 +1,47 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; +import { Response } from 'express'; +import { ClsService } from 'nestjs-cls'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + constructor( + @Inject(ILoggerRepository) private logger: ILoggerRepository, + private cls: ClsService, + ) { + this.logger.setContext(GlobalExceptionFilter.name); + } + + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const { status, body } = this.fromError(error); + if (!response.headersSent) { + response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + } + } + + private fromError(error: Error) { + logGlobalError(this.logger, error); + + if (error instanceof HttpException) { + const status = error.getStatus(); + let body = error.getResponse(); + + // unclear what circumstances would return a string + if (typeof body === 'string') { + body = { message: body }; + } + + return { status, body }; + } + + return { + status: 500, + body: { + message: 'Internal server error', + }, + }; + } +} diff --git a/server/src/middleware/http-exception.filter.ts b/server/src/middleware/http-exception.filter.ts deleted file mode 100644 index 3306b50ca6..0000000000 --- a/server/src/middleware/http-exception.filter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; -import { Response } from 'express'; -import { ClsService } from 'nestjs-cls'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; - -@Catch(HttpException) -export class HttpExceptionFilter implements ExceptionFilter { - constructor( - @Inject(ILoggerRepository) private logger: ILoggerRepository, - private cls: ClsService, - ) { - this.logger.setContext(HttpExceptionFilter.name); - } - - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const status = exception.getStatus(); - - this.logger.debug(`HttpException(${status}) ${JSON.stringify(exception.getResponse())}`); - - let responseBody = exception.getResponse(); - // unclear what circumstances would return a string - if (typeof responseBody === 'string') { - responseBody = { - error: 'Unknown', - message: responseBody, - statusCode: status, - }; - } - - if (!response.headersSent) { - response.status(status).json({ - ...responseBody, - correlationId: this.cls.getId(), - }); - } - } -} diff --git a/server/src/middleware/websocket.adapter.ts b/server/src/middleware/websocket.adapter.ts index 4978b16102..da5e5e9816 100644 --- a/server/src/middleware/websocket.adapter.ts +++ b/server/src/middleware/websocket.adapter.ts @@ -3,7 +3,7 @@ import { IoAdapter } from '@nestjs/platform-socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { Redis } from 'ioredis'; import { ServerOptions } from 'socket.io'; -import { parseRedisConfig } from 'src/config'; +import { IConfigRepository } from 'src/interfaces/config.interface'; export class WebSocketAdapter extends IoAdapter { constructor(private app: INestApplicationContext) { @@ -11,8 +11,9 @@ export class WebSocketAdapter extends IoAdapter { } createIOServer(port: number, options?: ServerOptions): any { + const { redis } = this.app.get(IConfigRepository).getEnv(); const server = super.createIOServer(port, options); - const pubClient = new Redis(parseRedisConfig()); + const pubClient = new Redis(redis); const subClient = pubClient.duplicate(); server.adapter(createAdapter(pubClient, subClient)); return server; diff --git a/server/src/migrations/1661881837496-AddAssetChecksum.ts b/server/src/migrations/1661881837496-AddAssetChecksum.ts index 231aeecca7..2901b4f554 100644 --- a/server/src/migrations/1661881837496-AddAssetChecksum.ts +++ b/server/src/migrations/1661881837496-AddAssetChecksum.ts @@ -11,7 +11,7 @@ export class AddAssetChecksum1661881837496 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`); + await queryRunner.query(`DROP INDEX "IDX_64c507300988dd1764f9a6530c"`); await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`); } } diff --git a/server/src/migrations/1670257571385-CreateTagsTable.ts b/server/src/migrations/1670257571385-CreateTagsTable.ts index 0585aecc8c..75fba9249c 100644 --- a/server/src/migrations/1670257571385-CreateTagsTable.ts +++ b/server/src/migrations/1670257571385-CreateTagsTable.ts @@ -17,8 +17,8 @@ export class CreateTagsTable1670257571385 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e99f31ea4cdf3a2c35c7287eb4"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f8e8a9e893cb5c54907f1b798e"`); + await queryRunner.query(`DROP INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4"`); + await queryRunner.query(`DROP INDEX "IDX_f8e8a9e893cb5c54907f1b798e"`); await queryRunner.query(`DROP TABLE "tag_asset"`); await queryRunner.query(`DROP TABLE "tags"`); } diff --git a/server/src/migrations/1673150490490-AddSharedLinkTable.ts b/server/src/migrations/1673150490490-AddSharedLinkTable.ts index a7508722d2..8d5bd2f5a5 100644 --- a/server/src/migrations/1673150490490-AddSharedLinkTable.ts +++ b/server/src/migrations/1673150490490-AddSharedLinkTable.ts @@ -18,10 +18,10 @@ export class AddSharedLinkTable1673150490490 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`); await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`); await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`); - await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`); - await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`); + await queryRunner.query(`DROP INDEX "IDX_c9fab4aa97ffd1b034f3d6581a"`); + await queryRunner.query(`DROP INDEX "IDX_5b7decce6c8d3db9593d6111a6"`); await queryRunner.query(`DROP TABLE "shared_link__asset"`); - await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`); + await queryRunner.query(`DROP INDEX "IDX_sharedlink_key"`); await queryRunner.query(`DROP TABLE "shared_links"`); } diff --git a/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts b/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts index 3be6a2aa1d..6f48ac736d 100644 --- a/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts +++ b/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts @@ -44,10 +44,10 @@ export class FixAlbumEntityTypeORM1675812532822 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621"`); await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_427c350ad49bd3935a50baab737"`); await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06"`); - await queryRunner.query(`DROP INDEX "public"."IDX_427c350ad49bd3935a50baab73"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f48513bf9bccefd6ff3ad30bd0"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e590fa396c6898fcd4a50e4092"`); - await queryRunner.query(`DROP INDEX "public"."IDX_4bd1303d199f4e72ccdf998c62"`); + await queryRunner.query(`DROP INDEX "IDX_427c350ad49bd3935a50baab73"`); + await queryRunner.query(`DROP INDEX "IDX_f48513bf9bccefd6ff3ad30bd0"`); + await queryRunner.query(`DROP INDEX "IDX_e590fa396c6898fcd4a50e4092"`); + await queryRunner.query(`DROP INDEX "IDX_4bd1303d199f4e72ccdf998c62"`); await queryRunner.query(`ALTER TABLE "albums" DROP CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4"`); await queryRunner.query( diff --git a/server/src/migrations/1676437878377-AppleContentIdentifier.ts b/server/src/migrations/1676437878377-AppleContentIdentifier.ts index 40a4dce579..8d11139878 100644 --- a/server/src/migrations/1676437878377-AppleContentIdentifier.ts +++ b/server/src/migrations/1676437878377-AppleContentIdentifier.ts @@ -9,7 +9,7 @@ export class AppleContentIdentifier1676437878377 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_live_photo_cid"`); + await queryRunner.query(`DROP INDEX "IDX_live_photo_cid"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "livePhotoCID"`); } } diff --git a/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts b/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts index 35d4c77eba..947559ed2d 100644 --- a/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts +++ b/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts @@ -6,7 +6,7 @@ export class ExifEntityDefinitionFixes1676848629119 implements MigrationInterfac public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "description" SET NOT NULL`); - await queryRunner.query(`DROP INDEX "public"."IDX_c0117fdbc50b917ef9067740c4"`); + await queryRunner.query(`DROP INDEX "IDX_c0117fdbc50b917ef9067740c4"`); await queryRunner.query(`ALTER TABLE "exif" DROP CONSTRAINT "PK_28663352d85078ad0046dafafaa"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "exif" DROP CONSTRAINT "FK_c0117fdbc50b917ef9067740c44"`); diff --git a/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts b/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts index f89c7acdd2..e089619c6d 100644 --- a/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts +++ b/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts @@ -4,7 +4,7 @@ export class SmartInfoEntityDefinitionFixes1676852143506 implements MigrationInt name = 'SmartInfoEntityDefinitionFixes1676852143506' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_5e3753aadd956110bf3ec0244a"`); + await queryRunner.query(`DROP INDEX "IDX_5e3753aadd956110bf3ec0244a"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP CONSTRAINT "PK_0beace66440e9713f5c40470e46"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP CONSTRAINT "FK_5e3753aadd956110bf3ec0244ac"`); diff --git a/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts b/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts index f3fb4a6c63..986b5ebd20 100644 --- a/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts +++ b/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts @@ -8,7 +8,7 @@ export class AddIndexForAlbumInSharedLinkTable1677535643119 implements Migration } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_albumId"`); + await queryRunner.query(`DROP INDEX "IDX_sharedlink_albumId"`); } } diff --git a/server/src/migrations/1684328185099-RequireChecksumNotNull.ts b/server/src/migrations/1684328185099-RequireChecksumNotNull.ts index 6da8f32622..e691fff2b1 100644 --- a/server/src/migrations/1684328185099-RequireChecksumNotNull.ts +++ b/server/src/migrations/1684328185099-RequireChecksumNotNull.ts @@ -4,13 +4,13 @@ export class RequireChecksumNotNull1684328185099 implements MigrationInterface { name = 'removeNotNullFromChecksumIndex1684328185099'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`); + await queryRunner.query(`DROP INDEX "IDX_64c507300988dd1764f9a6530c"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" SET NOT NULL`); await queryRunner.query(`CREATE INDEX "IDX_8d3efe36c0755849395e6ea866" ON "assets" ("checksum") `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_8d3efe36c0755849395e6ea866"`); + await queryRunner.query(`DROP INDEX "IDX_8d3efe36c0755849395e6ea866"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" DROP NOT NULL`); await queryRunner.query( `CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE ('checksum' IS NOT NULL)`, diff --git a/server/src/migrations/1692804658140-AddAuditTable.ts b/server/src/migrations/1692804658140-AddAuditTable.ts index 71b8c7b2c6..d398051a79 100644 --- a/server/src/migrations/1692804658140-AddAuditTable.ts +++ b/server/src/migrations/1692804658140-AddAuditTable.ts @@ -9,7 +9,7 @@ export class AddAuditTable1692804658140 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_ownerId_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_ownerId_createdAt"`); await queryRunner.query(`DROP TABLE "audit"`); } diff --git a/server/src/migrations/1696888644031-AddOriginalPathIndex.ts b/server/src/migrations/1696888644031-AddOriginalPathIndex.ts index 826700ffe8..78e1c92ecb 100644 --- a/server/src/migrations/1696888644031-AddOriginalPathIndex.ts +++ b/server/src/migrations/1696888644031-AddOriginalPathIndex.ts @@ -8,6 +8,6 @@ export class AddOriginalPathIndex1696888644031 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`); + await queryRunner.query(`DROP INDEX "IDX_originalPath_libraryId"`); } } diff --git a/server/src/migrations/1698693294632-AddActivity.ts b/server/src/migrations/1698693294632-AddActivity.ts index 46041570ea..5556ef2b20 100644 --- a/server/src/migrations/1698693294632-AddActivity.ts +++ b/server/src/migrations/1698693294632-AddActivity.ts @@ -15,7 +15,7 @@ export class AddActivity1698693294632 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_1af8519996fbfb3684b58df280b"`); await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea"`); await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_8091ea76b12338cb4428d33d782"`); - await queryRunner.query(`DROP INDEX "public"."IDX_activity_like"`); + await queryRunner.query(`DROP INDEX "IDX_activity_like"`); await queryRunner.query(`DROP TABLE "activity"`); } diff --git a/server/src/migrations/1700713871511-UsePgVectors.ts b/server/src/migrations/1700713871511-UsePgVectors.ts index 033e2ba9ad..e67c7275a7 100644 --- a/server/src/migrations/1700713871511-UsePgVectors.ts +++ b/server/src/migrations/1700713871511-UsePgVectors.ts @@ -1,13 +1,15 @@ -import { getVectorExtension } from 'src/database.config'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { getCLIPModelInfo } from 'src/utils/misc'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class UsePgVectors1700713871511 implements MigrationInterface { name = 'UsePgVectors1700713871511'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${getVectorExtension()}`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${vectorExtension}`); const faceDimQuery = await queryRunner.query(` SELECT CARDINALITY(embedding::real[]) as dimsize FROM asset_faces diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index e325f270fd..f9ea5a0dc3 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,12 +1,14 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface { name = 'AddCLIPEmbeddingIndex1700713994428'; public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } await queryRunner.query(`SET search_path TO "$user", public, vectors`); diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index bc6bad6dbd..d11e7b921e 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,12 +1,14 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface { name = 'AddFaceEmbeddingIndex1700714033632'; public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } await queryRunner.query(`SET search_path TO "$user", public, vectors`); diff --git a/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts b/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts index 723b22b3d1..38dd915139 100644 --- a/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts +++ b/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts @@ -9,8 +9,8 @@ export class AddAssetFaceIndicies1700752078178 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); - await queryRunner.query(`DROP INDEX "public"."IDX_bf339a24070dac7e71304ec530"`); + await queryRunner.query(`DROP INDEX "IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`DROP INDEX "IDX_bf339a24070dac7e71304ec530"`); } } diff --git a/server/src/migrations/1701665867595-AddExifCityIndex.ts b/server/src/migrations/1701665867595-AddExifCityIndex.ts index 9979762dc4..0899ea1e6b 100644 --- a/server/src/migrations/1701665867595-AddExifCityIndex.ts +++ b/server/src/migrations/1701665867595-AddExifCityIndex.ts @@ -8,7 +8,7 @@ export class AddExifCityIndex1701665867595 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."exif_city"`); + await queryRunner.query(`DROP INDEX "exif_city"`); } } diff --git a/server/src/migrations/1703035138085-AddAutoStackId.ts b/server/src/migrations/1703035138085-AddAutoStackId.ts index 6669142611..d8c83ac565 100644 --- a/server/src/migrations/1703035138085-AddAutoStackId.ts +++ b/server/src/migrations/1703035138085-AddAutoStackId.ts @@ -9,7 +9,7 @@ export class AddAutoStackId1703035138085 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_auto_stack_id"`); + await queryRunner.query(`DROP INDEX "IDX_auto_stack_id"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "autoStackId"`); } diff --git a/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts b/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts index b465d42943..c62c01f50c 100644 --- a/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts +++ b/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts @@ -8,6 +8,6 @@ export class AddOriginalFileNameIndex1705306747072 implements MigrationInterface } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_4d66e76dada1ca180f67a205dc"`); + await queryRunner.query(`DROP INDEX "IDX_4d66e76dada1ca180f67a205dc"`); } } diff --git a/server/src/migrations/1705363967169-CreateAssetStackTable.ts b/server/src/migrations/1705363967169-CreateAssetStackTable.ts index 74c75d555c..d1591797ff 100644 --- a/server/src/migrations/1705363967169-CreateAssetStackTable.ts +++ b/server/src/migrations/1705363967169-CreateAssetStackTable.ts @@ -41,7 +41,7 @@ export class CreateAssetStackTable1705197515600 implements MigrationInterface { ); // update constraints - await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`DROP INDEX "IDX_b463c8edb01364bf2beba08ef1"`); await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); await queryRunner.query( `ALTER TABLE "assets" ADD CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207" FOREIGN KEY ("stackId") REFERENCES "asset_stack"("id") ON DELETE SET NULL ON UPDATE CASCADE`, diff --git a/server/src/migrations/1711637874206-AddMemoryTable.ts b/server/src/migrations/1711637874206-AddMemoryTable.ts index 6309cb5082..b1c5b437d7 100644 --- a/server/src/migrations/1711637874206-AddMemoryTable.ts +++ b/server/src/migrations/1711637874206-AddMemoryTable.ts @@ -17,8 +17,8 @@ export class AddMemoryTable1711637874206 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f"`); await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e"`); await queryRunner.query(`ALTER TABLE "memories" DROP CONSTRAINT "FK_575842846f0c28fa5da46c99b19"`); - await queryRunner.query(`DROP INDEX "public"."IDX_6942ecf52d75d4273de19d2c16"`); - await queryRunner.query(`DROP INDEX "public"."IDX_984e5c9ab1f04d34538cd32334"`); + await queryRunner.query(`DROP INDEX "IDX_6942ecf52d75d4273de19d2c16"`); + await queryRunner.query(`DROP INDEX "IDX_984e5c9ab1f04d34538cd32334"`); await queryRunner.query(`DROP TABLE "memories_assets_assets"`); await queryRunner.query(`DROP TABLE "memories"`); } diff --git a/server/src/migrations/1715804005643-RemoveLibraryType.ts b/server/src/migrations/1715804005643-RemoveLibraryType.ts index d42ba4ec73..cd4dc574f2 100644 --- a/server/src/migrations/1715804005643-RemoveLibraryType.ts +++ b/server/src/migrations/1715804005643-RemoveLibraryType.ts @@ -5,8 +5,8 @@ export class RemoveLibraryType1715804005643 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c"`); - await queryRunner.query(`DROP INDEX "public"."UQ_assets_owner_library_checksum"`); - await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`); + await queryRunner.query(`DROP INDEX "UQ_assets_owner_library_checksum"`); + await queryRunner.query(`DROP INDEX "IDX_originalPath_libraryId"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" DROP NOT NULL`); await queryRunner.query(` UPDATE "assets" diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index c8e02ec0c5..ae6d752c65 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,10 +1,12 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } @@ -13,9 +15,10 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { const columns = await queryRunner.query( `SELECT column_name as name FROM information_schema.columns - WHERE table_name = '${tableName}'`); + WHERE table_name = '${tableName}'`, + ); return columns.some((column: { name: string }) => column.name === 'embedding'); - } + }; const hasAssetEmbeddings = await hasEmbeddings('smart_search'); if (!hasAssetEmbeddings) { @@ -31,7 +34,7 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); await queryRunner.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); - const hasFaceEmbeddings = await hasEmbeddings('asset_faces') + const hasFaceEmbeddings = await hasEmbeddings('asset_faces'); if (hasFaceEmbeddings) { await queryRunner.query(` INSERT INTO face_search("faceId", embedding) @@ -56,7 +59,7 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } diff --git a/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts b/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts new file mode 100644 index 0000000000..7f185077ff --- /dev/null +++ b/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSourceColumnToAssetFace1721249222549 implements MigrationInterface { + name = 'AddSourceColumnToAssetFace1721249222549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "sourceType" sourceType NOT NULL DEFAULT 'machine-learning'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "sourceType"`); + await queryRunner.query(`DROP TYPE sourceType`); + } + +} diff --git a/server/src/migrations/1722753178937-AddExifRating.ts b/server/src/migrations/1722753178937-AddExifRating.ts new file mode 100644 index 0000000000..52e8fb71e8 --- /dev/null +++ b/server/src/migrations/1722753178937-AddExifRating.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddRating1722753178937 implements MigrationInterface { + name = 'AddRating1722753178937' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "rating" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "rating"`); + } + +} diff --git a/server/src/migrations/1723719333525-AddApiKeyPermissions.ts b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts new file mode 100644 index 0000000000..d585d98bcb --- /dev/null +++ b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApiKeyPermissions1723719333525 implements MigrationInterface { + name = 'AddApiKeyPermissions1723719333525'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" ADD "permissions" character varying array NOT NULL DEFAULT '{all}'`); + await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "permissions" DROP DEFAULT`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "permissions"`); + } +} diff --git a/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts new file mode 100644 index 0000000000..a71ddfbcf3 --- /dev/null +++ b/server/src/migrations/1724080823160-AddThumbnailJobStatus.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddThumbnailJobStatus1724080823160 implements MigrationInterface { + name = 'AddThumbnailJobStatus1724080823160'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "previewAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "asset_job_status" ADD "thumbnailAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."previewPath" IS NOT NULL`); + await queryRunner.query(`UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "assets" WHERE "assetId" = "assets"."id" AND "assets"."thumbnailPath" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "thumbnailAt"`); + await queryRunner.query(`ALTER TABLE "asset_job_status" DROP COLUMN "previewAt"`); + } +} diff --git a/server/src/migrations/1724101822106-AddAssetFilesTable.ts b/server/src/migrations/1724101822106-AddAssetFilesTable.ts new file mode 100644 index 0000000000..bb086b084e --- /dev/null +++ b/server/src/migrations/1724101822106-AddAssetFilesTable.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetFilesTable1724101822106 implements MigrationInterface { + name = 'AddAssetFilesTable1724101822106' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "asset_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "assetId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "type" character varying NOT NULL, "path" character varying NOT NULL, CONSTRAINT "UQ_assetId_type" UNIQUE ("assetId", "type"), CONSTRAINT "PK_c41dc3e9ef5e1c57ca5a08a0004" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_asset_files_assetId" ON "asset_files" ("assetId") `); + await queryRunner.query(`ALTER TABLE "asset_files" ADD CONSTRAINT "FK_e3e103a5f1d8bc8402999286040" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + + // preview path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'preview', "previewPath" FROM "assets" WHERE "previewPath" IS NOT NULL AND "previewPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "previewPath"`); + + // thumbnail path migration + await queryRunner.query(`INSERT INTO "asset_files" ("assetId", "type", "path") SELECT "id", 'thumbnail', "thumbnailPath" FROM "assets" WHERE "thumbnailPath" IS NOT NULL AND "thumbnailPath" != ''`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "thumbnailPath"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // undo preview path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "previewPath" character varying`); + await queryRunner.query(`UPDATE "assets" SET "previewPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'preview'`); + + // undo thumbnail path migration + await queryRunner.query(`ALTER TABLE "assets" ADD "thumbnailPath" character varying DEFAULT ''`); + await queryRunner.query(`UPDATE "assets" SET "thumbnailPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'thumbnail'`); + + await queryRunner.query(`ALTER TABLE "asset_files" DROP CONSTRAINT "FK_e3e103a5f1d8bc8402999286040"`); + await queryRunner.query(`DROP INDEX "IDX_asset_files_assetId"`); + await queryRunner.query(`DROP TABLE "asset_files"`); + } + +} diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts new file mode 100644 index 0000000000..d468ff6ba4 --- /dev/null +++ b/server/src/migrations/1724790460210-NestedTagTable.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NestedTagTable1724790460210 implements MigrationInterface { + name = 'NestedTagTable1724790460210' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('TRUNCATE TABLE "tags" CASCADE'); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`); + await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`); + await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `); + await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`); + await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`); + await queryRunner.query(`DROP INDEX "IDX_b1a2a7ed45c29179b5ad51548a"`); + await queryRunner.query(`DROP INDEX "IDX_15fbcbc67663c6bfc07b354c22"`); + await queryRunner.query(`DROP TABLE "tags_closure"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/migrations/1725023079109-FixTagUniqueness.ts b/server/src/migrations/1725023079109-FixTagUniqueness.ts new file mode 100644 index 0000000000..859712621c --- /dev/null +++ b/server/src/migrations/1725023079109-FixTagUniqueness.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FixTagUniqueness1725023079109 implements MigrationInterface { + name = 'FixTagUniqueness1725023079109' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_79d6f16e52bb2c7130375246793" UNIQUE ("userId", "value")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_79d6f16e52bb2c7130375246793"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + } + +} diff --git a/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts new file mode 100644 index 0000000000..8eb47db438 --- /dev/null +++ b/server/src/migrations/1725258039306-UpsertMissingAssetJobStatus.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpsertMissingAssetJobStatus1725258039306 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO "asset_job_status" ("assetId", "facesRecognizedAt", "metadataExtractedAt", "duplicatesDetectedAt", "previewAt", "thumbnailAt") SELECT "assetId", NULL, NULL, NULL, NULL, NULL FROM "asset_files" f WHERE "f"."path" IS NOT NULL ON CONFLICT DO NOTHING`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "previewAt" = NOW() FROM "asset_files" f WHERE "previewAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'preview' AND "f"."path" IS NOT NULL`, + ); + + await queryRunner.query( + `UPDATE "asset_job_status" SET "thumbnailAt" = NOW() FROM "asset_files" f WHERE "thumbnailAt" IS NULL AND "asset_job_status"."assetId" = "f"."assetId" AND "f"."type" = 'thumbnail' AND "f"."path" IS NOT NULL`, + ); + } + + public async down(): Promise { + // do nothing + } +} diff --git a/server/src/migrations/1725327902980-RemoveThumbailAtForMissingThumbnails.ts b/server/src/migrations/1725327902980-RemoveThumbailAtForMissingThumbnails.ts new file mode 100644 index 0000000000..98a3fe403a --- /dev/null +++ b/server/src/migrations/1725327902980-RemoveThumbailAtForMissingThumbnails.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveThumbailAtForMissingThumbnails1725327902980 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "asset_job_status" j SET "thumbnailAt" = NULL WHERE j."thumbnailAt" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM asset_files f WHERE j."assetId" = f."assetId" AND f."type" = 'thumbnail' AND f."path" IS NOT NULL )`, + ); + } + + public async down(): Promise { + // do nothing + } +} diff --git a/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts new file mode 100644 index 0000000000..2dfb5b7978 --- /dev/null +++ b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveHiddenAssetsFromAlbums1725730782681 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "albums_assets_assets" WHERE "assetsId" IN (SELECT "id" FROM "assets" WHERE "isVisible" = false)`, + ); + } + + public async down(): Promise { + // noop + } +} diff --git a/server/src/migrations/1726491047923-AddprofileChangedAt.ts b/server/src/migrations/1726491047923-AddprofileChangedAt.ts new file mode 100644 index 0000000000..bcf568426a --- /dev/null +++ b/server/src/migrations/1726491047923-AddprofileChangedAt.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddprofileChangedAt1726491047923 implements MigrationInterface { + name = 'AddprofileChangedAt1726491047923' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "profileChangedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profileChangedAt"`); + } + +} diff --git a/server/src/migrations/1726593009549-AddAssetStatus.ts b/server/src/migrations/1726593009549-AddAssetStatus.ts new file mode 100644 index 0000000000..5b243b05b5 --- /dev/null +++ b/server/src/migrations/1726593009549-AddAssetStatus.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetStatus1726593009549 implements MigrationInterface { + name = 'AddAssetStatus1726593009549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "assets_status_enum" AS ENUM('active', 'trashed', 'deleted')`); + await queryRunner.query(`ALTER TABLE "assets" ADD "status" "assets_status_enum" NOT NULL DEFAULT 'active'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "status"`); + await queryRunner.query(`DROP TYPE "assets_status_enum"`); + } + +} diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000..e02203997f --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts b/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts new file mode 100644 index 0000000000..050e9a93cf --- /dev/null +++ b/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class IsOfflineSetDeletedAt1727781844613 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE assets SET "deletedAt" = now() WHERE "isOffline" = true AND "deletedAt" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE assets SET "deletedAt" = null WHERE "isOffline" = true`, + ); + } +} diff --git a/server/src/migrations/1727797340951-AddVersionHistory.ts b/server/src/migrations/1727797340951-AddVersionHistory.ts new file mode 100644 index 0000000000..7eb731d1a3 --- /dev/null +++ b/server/src/migrations/1727797340951-AddVersionHistory.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddVersionHistory1727797340951 implements MigrationInterface { + name = 'AddVersionHistory1727797340951' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "version" character varying NOT NULL, CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "version_history"`); + } + +} diff --git a/server/src/migrations/1729793521993-AddAlbumAssetCreatedAt.ts b/server/src/migrations/1729793521993-AddAlbumAssetCreatedAt.ts new file mode 100644 index 0000000000..280b34890d --- /dev/null +++ b/server/src/migrations/1729793521993-AddAlbumAssetCreatedAt.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAlbumAssetCreatedAt1729793521993 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "albums_assets_assets" ADD COLUMN "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP COLUMN "createdAt"`); + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index ffe4b6413f..ad57eac0ad 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -248,6 +248,28 @@ WHERE "partner"."sharedById" IN ($1) AND "partner"."sharedWithId" = $2 +-- AccessRepository.stack.checkOwnerAccess +SELECT + "StackEntity"."id" AS "StackEntity_id" +FROM + "asset_stack" "StackEntity" +WHERE + ( + ("StackEntity"."id" IN ($1)) + AND ("StackEntity"."ownerId" = $2) + ) + +-- AccessRepository.tag.checkOwnerAccess +SELECT + "TagEntity"."id" AS "TagEntity_id" +FROM + "tags" "TagEntity" +WHERE + ( + ("TagEntity"."id" IN ($1)) + AND ("TagEntity"."userId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 3f3e04140c..44042c0e6d 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -23,7 +23,8 @@ SELECT "ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status", "ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt", "ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes", - "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes" + "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes", + "ActivityEntity__ActivityEntity_user"."profileChangedAt" AS "ActivityEntity__ActivityEntity_user_profileChangedAt" FROM "activity" "ActivityEntity" LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId" diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 729f7c7f20..c4f6fbdd32 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -30,6 +30,7 @@ FROM "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", @@ -47,6 +48,7 @@ FROM "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -80,64 +82,6 @@ ORDER BY LIMIT 1 --- AlbumRepository.getByIds -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", - "AlbumEntity"."albumName" AS "AlbumEntity_albumName", - "AlbumEntity"."description" AS "AlbumEntity_description", - "AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", - "AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", - "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", - "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", - "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", - "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", - "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", - "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", - "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", - "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", - "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", - "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", - "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", - "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", - "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", - "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", - "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", - "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", - "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" -FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) - LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" - AND ( - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL - ) -WHERE - ((("AlbumEntity"."id" IN ($1)))) - AND ("AlbumEntity"."deletedAt" IS NULL) - -- AlbumRepository.getByAssetId SELECT "AlbumEntity"."id" AS "AlbumEntity_id", @@ -164,6 +108,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", @@ -180,7 +125,8 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" @@ -241,35 +187,6 @@ WHERE GROUP BY "album"."id" --- AlbumRepository.getInvalidThumbnail -SELECT - "albums"."id" AS "albums_id" -FROM - "albums" "albums" -WHERE - ( - "albums"."albumThumbnailAssetId" IS NULL - AND EXISTS ( - SELECT - 1 - FROM - "albums_assets_assets" "albums_assets" - WHERE - "albums"."id" = "albums_assets"."albumsId" - ) - OR "albums"."albumThumbnailAssetId" IS NOT NULL - AND NOT EXISTS ( - SELECT - 1 - FROM - "albums_assets_assets" "albums_assets" - WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" - ) - ) - AND ("albums"."deletedAt" IS NULL) - -- AlbumRepository.getOwned SELECT "AlbumEntity"."id" AS "AlbumEntity_id", @@ -299,6 +216,7 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -324,7 +242,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -372,6 +291,7 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -397,7 +317,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -495,7 +416,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -528,41 +450,6 @@ WHERE ORDER BY "AlbumEntity"."createdAt" DESC --- AlbumRepository.getAll -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", - "AlbumEntity"."albumName" AS "AlbumEntity_albumName", - "AlbumEntity"."description" AS "AlbumEntity_description", - "AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", - "AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", - "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", - "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", - "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", - "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", - "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", - "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", - "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", - "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", - "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", - "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", - "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", - "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", - "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", - "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" -FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) -WHERE - "AlbumEntity"."deletedAt" IS NULL - -- AlbumRepository.removeAsset DELETE FROM "albums_assets_assets" WHERE @@ -596,16 +483,13 @@ UPDATE "albums" SET "albumThumbnailAssetId" = ( SELECT - "albums_assets2"."assetsId" + "album_assets"."assetsId" FROM - "assets" "assets", - "albums_assets_assets" "albums_assets2" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - ( - "albums_assets2"."assetsId" = "assets"."id" - AND "albums_assets2"."albumsId" = "albums"."id" - ) - AND ("assets"."deletedAt" IS NULL) + "album_assets"."albumsId" = "albums"."id" ORDER BY "assets"."fileCreatedAt" DESC LIMIT @@ -618,17 +502,21 @@ WHERE SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" + "album_assets"."albumsId" = "albums"."id" ) OR "albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" + "album_assets"."albumsId" = "albums"."id" + AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index ba54a6e67c..f4989d355e 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -9,6 +9,7 @@ FROM "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."key" AS "APIKeyEntity_key", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity__APIKeyEntity_user"."id" AS "APIKeyEntity__APIKeyEntity_user_id", "APIKeyEntity__APIKeyEntity_user"."name" AS "APIKeyEntity__APIKeyEntity_user_name", "APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin", @@ -23,6 +24,7 @@ FROM "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes", + "APIKeyEntity__APIKeyEntity_user"."profileChangedAt" AS "APIKeyEntity__APIKeyEntity_user_profileChangedAt", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value" @@ -46,6 +48,7 @@ SELECT "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."name" AS "APIKeyEntity_name", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt", "APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt" FROM @@ -63,6 +66,7 @@ SELECT "APIKeyEntity"."id" AS "APIKeyEntity_id", "APIKeyEntity"."name" AS "APIKeyEntity_name", "APIKeyEntity"."userId" AS "APIKeyEntity_userId", + "APIKeyEntity"."permissions" AS "APIKeyEntity_permissions", "APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt", "APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt" FROM diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ba0707cfe7..98edc8da1d 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -8,9 +8,8 @@ SELECT "entity"."libraryId" AS "entity_libraryId", "entity"."deviceId" AS "entity_deviceId", "entity"."type" AS "entity_type", + "entity"."status" AS "entity_status", "entity"."originalPath" AS "entity_originalPath", - "entity"."previewPath" AS "entity_previewPath", - "entity"."thumbnailPath" AS "entity_thumbnailPath", "entity"."thumbhash" AS "entity_thumbhash", "entity"."encodedVideoPath" AS "entity_encodedVideoPath", "entity"."createdAt" AS "entity_createdAt", @@ -58,16 +57,23 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", - "exifInfo"."fps" AS "exifInfo_fps" + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps", + "files"."id" AS "files_id", + "files"."assetId" AS "files_assetId", + "files"."createdAt" AS "files_createdAt", + "files"."updatedAt" AS "files_updatedAt", + "files"."type" AS "files_type", + "files"."path" AS "files_path" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" + LEFT JOIN "asset_files" "files" ON "files"."assetId" = "entity"."id" WHERE ( "entity"."ownerId" IN ($1) AND "entity"."isVisible" = true AND "entity"."isArchived" = false - AND "entity"."previewPath" IS NOT NULL AND EXTRACT( DAY FROM @@ -81,7 +87,7 @@ WHERE ) AND ("entity"."deletedAt" IS NULL) ORDER BY - "entity"."localDateTime" ASC + "entity"."fileCreatedAt" ASC -- AssetRepository.getByIds SELECT @@ -91,9 +97,8 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -127,9 +132,8 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -177,15 +181,18 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", + "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id", - "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type", - "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name", + "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value", + "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt", + "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt", + "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color", + "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId", "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", - "AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", @@ -195,6 +202,7 @@ SELECT "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", + "AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType", "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", @@ -213,9 +221,8 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId", "bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId", "bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type", + "bd93d5747511a4dad4923546c51365bf1a803774"."status" AS "bd93d5747511a4dad4923546c51365bf1a803774_status", "bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."previewPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_previewPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."thumbnailPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbnailPath", "bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash", "bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath", "bd93d5747511a4dad4923546c51365bf1a803774"."createdAt" AS "bd93d5747511a4dad4923546c51365bf1a803774_createdAt", @@ -235,7 +242,13 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName", "bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath", "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId", - "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId" + "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId", + "AssetEntity__AssetEntity_files"."id" AS "AssetEntity__AssetEntity_files_id", + "AssetEntity__AssetEntity_files"."assetId" AS "AssetEntity__AssetEntity_files_assetId", + "AssetEntity__AssetEntity_files"."createdAt" AS "AssetEntity__AssetEntity_files_createdAt", + "AssetEntity__AssetEntity_files"."updatedAt" AS "AssetEntity__AssetEntity_files_updatedAt", + "AssetEntity__AssetEntity_files"."type" AS "AssetEntity__AssetEntity_files_type", + "AssetEntity__AssetEntity_files"."path" AS "AssetEntity__AssetEntity_files_path" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -246,6 +259,7 @@ FROM LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" LEFT JOIN "asset_stack" "AssetEntity__AssetEntity_stack" ON "AssetEntity__AssetEntity_stack"."id" = "AssetEntity"."stackId" LEFT JOIN "assets" "bd93d5747511a4dad4923546c51365bf1a803774" ON "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" = "AssetEntity__AssetEntity_stack"."id" + LEFT JOIN "asset_files" "AssetEntity__AssetEntity_files" ON "AssetEntity__AssetEntity_files"."assetId" = "AssetEntity"."id" WHERE (("AssetEntity"."id" IN ($1))) @@ -254,35 +268,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getExternalLibraryAssetPaths -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) - WHERE - ( - ( - ((("AssetEntity__AssetEntity_library"."id" = $1))) - AND ("AssetEntity"."isExternal" = $2) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC -LIMIT - 2 - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -295,9 +280,8 @@ FROM "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -353,18 +337,6 @@ WHERE AND "originalPath" = path ); --- AssetRepository.updateOfflineLibraryAssets -UPDATE "assets" -SET - "isOffline" = $1, - "updatedAt" = CURRENT_TIMESTAMP -WHERE - ( - "libraryId" = $2 - AND NOT ("originalPath" IN ($3)) - AND "isOffline" = $4 - ) - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", @@ -394,9 +366,8 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -449,9 +420,8 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -489,6 +459,7 @@ LIMIT -- AssetRepository.getByChecksums SELECT "AssetEntity"."id" AS "AssetEntity_id", + "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", "AssetEntity"."checksum" AS "AssetEntity_checksum" FROM "assets" "AssetEntity" @@ -522,9 +493,8 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", "AssetEntity"."createdAt" AS "AssetEntity_createdAt", @@ -578,9 +548,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -628,6 +597,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -638,9 +608,8 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -719,9 +688,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -769,6 +737,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -779,9 +748,8 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -836,9 +804,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -886,6 +853,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -896,9 +864,8 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -1003,9 +970,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1053,6 +1019,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1079,9 +1046,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -1129,6 +1095,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", @@ -1141,3 +1108,51 @@ WHERE "asset"."isVisible" = true AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 + +-- AssetRepository.upsertFile +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" + +-- AssetRepository.upsertFiles +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index bc20bf4bd3..a5d6ba05db 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -28,7 +28,8 @@ FROM "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" @@ -68,7 +69,8 @@ SELECT "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" @@ -104,7 +106,8 @@ SELECT "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" @@ -145,14 +148,3 @@ WHERE AND ("libraries"."deletedAt" IS NULL) GROUP BY "libraries"."id" - --- LibraryRepository.getAssetIds -SELECT - "assets"."id" AS "assets_id" -FROM - "libraries" "library" - INNER JOIN "assets" "assets" ON "assets"."libraryId" = "library"."id" - AND ("assets"."deletedAt" IS NULL) -WHERE - ("library"."id" = $1) - AND ("library"."deletedAt" IS NULL) diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql deleted file mode 100644 index bed7d59ab6..0000000000 --- a/server/src/queries/metadata.repository.sql +++ /dev/null @@ -1,66 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- MetadataRepository.getCountries -SELECT DISTINCT - ON ("exif"."country") "exif"."country" AS "exif_country", - "exif"."assetId" AS "exif_assetId" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."country" IS NOT NULL - --- MetadataRepository.getStates -SELECT DISTINCT - ON ("exif"."state") "exif"."state" AS "exif_state", - "exif"."assetId" AS "exif_assetId" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."state" IS NOT NULL - AND "exif"."country" = $2 - --- MetadataRepository.getCities -SELECT DISTINCT - ON ("exif"."city") "exif"."city" AS "exif_city", - "exif"."assetId" AS "exif_assetId" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."city" IS NOT NULL - AND "exif"."country" = $2 - AND "exif"."state" = $3 - --- MetadataRepository.getCameraMakes -SELECT DISTINCT - ON ("exif"."make") "exif"."make" AS "exif_make", - "exif"."assetId" AS "exif_assetId" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."make" IS NOT NULL - AND "exif"."model" = $2 - --- MetadataRepository.getCameraModels -SELECT DISTINCT - ON ("exif"."model") "exif"."model" AS "exif_model", - "exif"."assetId" AS "exif_assetId" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."model" IS NOT NULL - AND "exif"."make" = $2 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 4e4d36da8b..5616559d7d 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -74,6 +74,7 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -106,6 +107,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -141,6 +143,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -156,9 +159,8 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", @@ -214,19 +216,24 @@ SELECT "person"."isHidden" AS "person_isHidden" FROM "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" WHERE "person"."ownerId" = $1 AND ( LOWER("person"."name") LIKE $2 OR LOWER("person"."name") LIKE $3 ) -GROUP BY - "person"."id" -ORDER BY - COUNT("face"."assetId") DESC LIMIT - 20 + 1000 + +-- PersonRepository.getDistinctNames +SELECT DISTINCT + ON (lower("person"."name")) "person"."id" AS "person_id", + "person"."name" AS "person_name" +FROM + "person" "person" +WHERE + "person"."ownerId" = $1 + AND "person"."name" != '' -- PersonRepository.getStatistics SELECT @@ -241,113 +248,6 @@ WHERE AND "asset"."deletedAt" IS NULL AND "asset"."livePhotoVideoId" IS NULL --- PersonRepository.getAssets -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id", - "distinctAlias"."AssetEntity_fileCreatedAt" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", - "AssetEntity"."ownerId" AS "AssetEntity_ownerId", - "AssetEntity"."libraryId" AS "AssetEntity_libraryId", - "AssetEntity"."deviceId" AS "AssetEntity_deviceId", - "AssetEntity"."type" AS "AssetEntity_type", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."previewPath" AS "AssetEntity_previewPath", - "AssetEntity"."thumbnailPath" AS "AssetEntity_thumbnailPath", - "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", - "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", - "AssetEntity"."createdAt" AS "AssetEntity_createdAt", - "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", - "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", - "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", - "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", - "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", - "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", - "AssetEntity"."isArchived" AS "AssetEntity_isArchived", - "AssetEntity"."isExternal" AS "AssetEntity_isExternal", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline", - "AssetEntity"."checksum" AS "AssetEntity_checksum", - "AssetEntity"."duration" AS "AssetEntity_duration", - "AssetEntity"."isVisible" AS "AssetEntity_isVisible", - "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", - "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", - "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId", - "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId", - "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", - "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", - "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", - "AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth", - "AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight", - "AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1", - "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", - "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", - "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."ownerId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_ownerId", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."name" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_name", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."birthDate" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_birthDate", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", - "AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", - "AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", - "AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", - "AssetEntity__AssetEntity_exifInfo"."exifImageHeight" AS "AssetEntity__AssetEntity_exifInfo_exifImageHeight", - "AssetEntity__AssetEntity_exifInfo"."fileSizeInByte" AS "AssetEntity__AssetEntity_exifInfo_fileSizeInByte", - "AssetEntity__AssetEntity_exifInfo"."orientation" AS "AssetEntity__AssetEntity_exifInfo_orientation", - "AssetEntity__AssetEntity_exifInfo"."dateTimeOriginal" AS "AssetEntity__AssetEntity_exifInfo_dateTimeOriginal", - "AssetEntity__AssetEntity_exifInfo"."modifyDate" AS "AssetEntity__AssetEntity_exifInfo_modifyDate", - "AssetEntity__AssetEntity_exifInfo"."timeZone" AS "AssetEntity__AssetEntity_exifInfo_timeZone", - "AssetEntity__AssetEntity_exifInfo"."latitude" AS "AssetEntity__AssetEntity_exifInfo_latitude", - "AssetEntity__AssetEntity_exifInfo"."longitude" AS "AssetEntity__AssetEntity_exifInfo_longitude", - "AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", - "AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", - "AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", - "AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", - "AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", - "AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", - "AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", - "AssetEntity__AssetEntity_exifInfo"."model" AS "AssetEntity__AssetEntity_exifInfo_model", - "AssetEntity__AssetEntity_exifInfo"."lensModel" AS "AssetEntity__AssetEntity_exifInfo_lensModel", - "AssetEntity__AssetEntity_exifInfo"."fNumber" AS "AssetEntity__AssetEntity_exifInfo_fNumber", - "AssetEntity__AssetEntity_exifInfo"."focalLength" AS "AssetEntity__AssetEntity_exifInfo_focalLength", - "AssetEntity__AssetEntity_exifInfo"."iso" AS "AssetEntity__AssetEntity_exifInfo_iso", - "AssetEntity__AssetEntity_exifInfo"."exposureTime" AS "AssetEntity__AssetEntity_exifInfo_exposureTime", - "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", - "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", - "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" - FROM - "assets" "AssetEntity" - LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" - LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" - LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" - WHERE - ( - ( - ( - ( - ("AssetEntity__AssetEntity_faces"."personId" = $1) - ) - ) - AND ("AssetEntity"."isVisible" = $2) - AND ("AssetEntity"."isArchived" = $3) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "distinctAlias"."AssetEntity_fileCreatedAt" DESC, - "AssetEntity_id" ASC -LIMIT - 1000 - -- PersonRepository.getNumberOfPeople SELECT COUNT(DISTINCT ("person"."id")) AS "total", @@ -378,15 +278,15 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", - "AssetFaceEntity__AssetFaceEntity_asset"."previewPath" AS "AssetFaceEntity__AssetFaceEntity_asset_previewPath", - "AssetFaceEntity__AssetFaceEntity_asset"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", @@ -430,7 +330,8 @@ SELECT "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2" + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType" FROM "asset_faces" "AssetFaceEntity" WHERE diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58a288a0cd..cd9a84b016 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -13,9 +13,8 @@ FROM "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -45,9 +44,8 @@ FROM "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -79,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -93,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET @@ -110,9 +293,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -142,9 +324,8 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."previewPath" AS "stackedAssets_previewPath", - "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", "stackedAssets"."createdAt" AS "stackedAssets_createdAt", @@ -243,6 +424,7 @@ WITH "faces"."boundingBoxY1" AS "boundingBoxY1", "faces"."boundingBoxX2" AS "boundingBoxX2", "faces"."boundingBoxY2" AS "boundingBoxY2", + "faces"."sourceType" AS "sourceType", "search"."embedding" <= > $1 AS "distance" FROM "asset_faces" "faces" @@ -352,9 +534,8 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", - "asset"."previewPath" AS "asset_previewPath", - "asset"."thumbnailPath" AS "asset_thumbnailPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", "asset"."createdAt" AS "asset_createdAt", @@ -402,6 +583,7 @@ SELECT "exif"."profileDescription" AS "exif_profileDescription", "exif"."colorspace" AS "exif_colorspace", "exif"."bitsPerSample" AS "exif_bitsPerSample", + "exif"."rating" AS "exif_rating", "exif"."fps" AS "exif_fps" FROM "assets" "asset" @@ -409,3 +591,58 @@ FROM INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city + +-- SearchRepository.getCountries +SELECT DISTINCT + ON ("exif"."country") "exif"."country" AS "country" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + +-- SearchRepository.getStates +SELECT DISTINCT + ON ("exif"."state") "exif"."state" AS "state" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + +-- SearchRepository.getCities +SELECT DISTINCT + ON ("exif"."city") "exif"."city" AS "city" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + AND "exif"."state" = $3 + +-- SearchRepository.getCameraMakes +SELECT DISTINCT + ON ("exif"."make") "exif"."make" AS "make" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."model" = $2 + +-- SearchRepository.getCameraModels +SELECT DISTINCT + ON ("exif"."model") "exif"."model" AS "model" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."make" = $2 diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 17fff94f42..2f0613b4d0 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -39,6 +39,7 @@ FROM "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes", + "SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 09f0cf7cb5..a19b698f76 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -27,9 +27,8 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId", "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", + "SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", @@ -77,6 +76,7 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."profileDescription" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_profileDescription", "9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace", "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", @@ -94,9 +94,8 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."libraryId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_libraryId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type", + "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."status" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_status", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."previewPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_previewPath", - "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbnailPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbnailPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."createdAt" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_createdAt", @@ -144,6 +143,7 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."profileDescription" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_profileDescription", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", @@ -158,7 +158,8 @@ FROM "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" @@ -215,9 +216,8 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId", "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", + "SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", - "SharedLinkEntity__SharedLinkEntity_assets"."previewPath" AS "SharedLinkEntity__SharedLinkEntity_assets_previewPath", - "SharedLinkEntity__SharedLinkEntity_assets"."thumbnailPath" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbnailPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", "SharedLinkEntity__SharedLinkEntity_assets"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_assets_createdAt", @@ -261,7 +261,8 @@ SELECT "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" @@ -313,7 +314,8 @@ FROM "SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", "SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes", - "SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes" + "SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes", + "SharedLinkEntity__SharedLinkEntity_user"."profileChangedAt" AS "SharedLinkEntity__SharedLinkEntity_user_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId" diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql new file mode 100644 index 0000000000..ba1aac82b3 --- /dev/null +++ b/server/src/queries/tag.repository.sql @@ -0,0 +1,30 @@ +-- 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.addAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) + +-- TagRepository.removeAssetIds +DELETE FROM "tag_asset" +WHERE + ( + "tagsId" = $1 + AND "assetsId" IN ($2) + ) + +-- TagRepository.upsertAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 2c75786f97..ab0a6cc534 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -15,7 +15,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE @@ -60,7 +61,8 @@ SELECT "user"."status" AS "user_status", "user"."updatedAt" AS "user_updatedAt", "user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", - "user"."quotaUsageInBytes" AS "user_quotaUsageInBytes" + "user"."quotaUsageInBytes" AS "user_quotaUsageInBytes", + "user"."profileChangedAt" AS "user_profileChangedAt" FROM "users" "user" WHERE @@ -82,7 +84,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE @@ -106,7 +109,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql new file mode 100644 index 0000000000..e5b88ffef9 --- /dev/null +++ b/server/src/queries/view.repository.sql @@ -0,0 +1,79 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- ViewRepository.getAssetsByOriginalPath +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps" +FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" +WHERE + ( + ( + "asset"."isVisible" = $1 + AND "asset"."isArchived" = $2 + AND "asset"."ownerId" = $3 + ) + AND ( + "asset"."originalPath" LIKE $4 + AND "asset"."originalPath" NOT LIKE $5 + ) + ) + AND ("asset"."deletedAt" IS NULL) +ORDER BY + regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 9dd294cc21..f3cbf392db 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { ActivityEntity } from 'src/entities/activity.entity'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -12,20 +11,23 @@ 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 { StackEntity } from 'src/entities/stack.entity'; +import { TagEntity } from 'src/entities/tag.entity'; +import { AlbumUserRole } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Brackets, In, Repository } from 'typeorm'; type IActivityAccess = IAccessRepository['activity']; type IAlbumAccess = IAccessRepository['album']; type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; -type ITimelineAccess = IAccessRepository['timeline']; type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; type IPartnerAccess = IAccessRepository['partner']; +type IStackAccess = IAccessRepository['stack']; +type ITagAccess = IAccessRepository['tag']; +type ITimelineAccess = IAccessRepository['timeline']; -@Instrumentation() @Injectable() class ActivityAccess implements IActivityAccess { constructor( @@ -313,6 +315,28 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } +class StackAccess implements IStackAccess { + constructor(private stackRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, stackIds: Set): Promise> { + if (stackIds.size === 0) { + return new Set(); + } + + return this.stackRepository + .find({ + select: { id: true }, + where: { + id: In([...stackIds]), + ownerId: userId, + }, + }) + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class TimelineAccess implements ITimelineAccess { constructor(private partnerRepository: Repository) {} @@ -420,6 +444,28 @@ class PartnerAccess implements IPartnerAccess { } } +class TagAccess implements ITagAccess { + constructor(private tagRepository: Repository) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, tagIds: Set): Promise> { + if (tagIds.size === 0) { + return new Set(); + } + + return this.tagRepository + .find({ + select: { id: true }, + where: { + id: In([...tagIds]), + userId, + }, + }) + .then((tags) => new Set(tags.map((tag) => tag.id))); + } +} + export class AccessRepository implements IAccessRepository { activity: IActivityAccess; album: IAlbumAccess; @@ -428,6 +474,8 @@ export class AccessRepository implements IAccessRepository { memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; + stack: IStackAccess; + tag: ITagAccess; timeline: ITimelineAccess; constructor( @@ -441,6 +489,8 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, @InjectRepository(SessionEntity) sessionRepository: Repository, + @InjectRepository(StackEntity) stackRepository: Repository, + @InjectRepository(TagEntity) tagRepository: Repository, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); @@ -449,6 +499,8 @@ export class AccessRepository implements IAccessRepository { this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); + this.stack = new StackAccess(stackRepository); + this.tag = new TagAccess(tagRepository); this.timeline = new TimelineAccess(partnerRepository); } } diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index e21f746483..0f0a0cb60e 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ActivityEntity } from 'src/entities/activity.entity'; import { IActivityRepository } from 'src/interfaces/activity.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { IsNull, Repository } from 'typeorm'; export interface ActivitySearch { @@ -13,7 +12,6 @@ export interface ActivitySearch { isLiked?: boolean; } -@Instrumentation() @Injectable() export class ActivityRepository implements IActivityRepository { constructor(@InjectRepository(ActivityEntity) private repository: Repository) {} diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index 7fd18711aa..9328ea8cfc 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumPermissionId, IAlbumUserRepository } from 'src/interfaces/album-user.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class AlbumUserRepository implements IAlbumUserRepository { constructor(@InjectRepository(AlbumUserEntity) private repository: Repository) {} diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index fd3a89993a..8b7565e318 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -4,7 +4,6 @@ import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { DataSource, EntityManager, @@ -23,7 +22,6 @@ const withoutDeletedUsers = (album: T) => { return album; }; -@Instrumentation() @Injectable() export class AlbumRepository implements IAlbumRepository { constructor( @@ -57,22 +55,6 @@ export class AlbumRepository implements IAlbumRepository { return withoutDeletedUsers(album); } - @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - async getByIds(ids: string[]): Promise { - const albums = await this.repository.find({ - where: { - id: In(ids), - }, - relations: { - owner: true, - albumUsers: { user: true }, - }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async getByAssetId(ownerId: string, assetId: string): Promise { const albums = await this.repository.find({ @@ -116,34 +98,6 @@ export class AlbumRepository implements IAlbumRepository { })); } - /** - * Returns the album IDs that have an invalid thumbnail, when: - * - Thumbnail references an asset outside the album - * - Empty album still has a thumbnail set - */ - @GenerateSql() - async getInvalidThumbnail(): Promise { - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); - - const albumContainsThumbnail = albumHasAssets - .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); - - const albums = await this.repository - .createQueryBuilder('albums') - .select('albums.id') - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`) - .getMany(); - - return albums.map((album) => album.id); - } - @GenerateSql({ params: [DummyValue.UUID] }) async getOwned(ownerId: string): Promise { const albums = await this.repository.find({ @@ -199,15 +153,6 @@ export class AlbumRepository implements IAlbumRepository { await this.repository.delete({ ownerId: userId }); } - @GenerateSql() - getAll(): Promise { - return this.repository.find({ - relations: { - owner: true, - }, - }); - } - @GenerateSql({ params: [DummyValue.UUID] }) async removeAsset(assetId: string): Promise { // Using dataSource, because there is no direct access to albums_assets_assets. @@ -330,32 +275,26 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql() async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); + const builder = this.dataSource + .createQueryBuilder('albums_assets_assets', 'album_assets') + .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') + .where('"album_assets"."albumsId" = "albums"."id"'); - const albumContainsThumbnail = albumHasAssets + const newThumbnail = builder .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + .select('"album_assets"."assetsId"') + .orderBy('"assets"."fileCreatedAt"', 'DESC') + .limit(1); + const hasAssets = builder.clone().select('1'); + const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"'); const updateAlbums = this.repository .createQueryBuilder('albums') .update(AlbumEntity) .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); const result = await updateAlbums.execute(); diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index c5cdb80551..bb37390de1 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { APIKeyEntity } from 'src/entities/api-key.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class ApiKeyRepository implements IKeyRepository { constructor(@InjectRepository(APIKeyEntity) private repository: Repository) {} @@ -31,6 +29,7 @@ export class ApiKeyRepository implements IKeyRepository { id: true, key: true, userId: true, + permissions: true, }, where: { key: hashedToken }, relations: { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index cc9fac4652..a50dd0f79c 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,18 +1,18 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetOrder } from 'src/entities/album.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, PaginationMode } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, - AssetPathEntity, AssetStats, AssetStatsOptions, AssetUpdateAllOptions, @@ -29,8 +29,7 @@ import { } from 'src/interfaces/asset.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { searchAssetBuilder } from 'src/utils/database'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { Brackets, FindOptionsOrder, @@ -54,11 +53,11 @@ const dateTrunc = (options: TimeBucketOptions) => truncateMap[options.size] }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; -@Instrumentation() @Injectable() export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, + @InjectRepository(AssetFileEntity) private fileRepository: Repository, @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, @@ -84,7 +83,6 @@ export class AssetRepository implements IAssetRepository { `entity.ownerId IN (:...ownerIds) AND entity.isVisible = true AND entity.isArchived = false - AND entity.previewPath IS NOT NULL AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, { @@ -94,7 +92,8 @@ export class AssetRepository implements IAssetRepository { }, ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .orderBy('entity.localDateTime', 'ASC') + .leftJoinAndSelect('entity.files', 'files') + .orderBy('entity.fileCreatedAt', 'ASC') .getMany(); } @@ -128,6 +127,7 @@ export class AssetRepository implements IAssetRepository { stack: { assets: true, }, + files: true, }, withDeleted: true, }); @@ -174,14 +174,6 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { - select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId }, isExternal: true }, - }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { return this.repository.findOne({ @@ -195,26 +187,18 @@ export class AssetRepository implements IAssetRepository { async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { const result = await this.repository.query( ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path + FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, [libraryId, originalPaths], ); return result.map((row: { path: string }) => row.path); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { - let builder = this.repository.createQueryBuilder('asset'); + let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); return paginatedBuilder(builder, { @@ -292,16 +276,6 @@ export class AssetRepository implements IAssetRepository { .execute(); } - @Chunked() - async softDeleteAll(ids: string[]): Promise { - await this.repository.softDelete({ id: In(ids) }); - } - - @Chunked() - async restoreAll(ids: string[]): Promise { - await this.repository.restore({ id: In(ids) }); - } - async update(asset: AssetUpdateOptions): Promise { await this.repository.update(asset.id, asset); } @@ -335,6 +309,7 @@ export class AssetRepository implements IAssetRepository { select: { id: true, checksum: true, + deletedAt: true, }, where: { ownerId, @@ -360,12 +335,13 @@ export class AssetRepository implements IAssetRepository { } findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { - const { ownerId, otherAssetId, livePhotoCID, type } = options; + const { ownerId, libraryId, otherAssetId, livePhotoCID, type } = options; return this.repository.findOne({ where: { id: Not(otherAssetId), ownerId, + libraryId: libraryId || IsNull(), type, exifInfo: { livePhotoCID, @@ -378,12 +354,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql( - ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE) - .map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), + ...Object.values(WithProperty).map((property) => ({ + name: property, + params: [DummyValue.PAGINATION, property], + })), ) getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { let relations: FindOptionsRelations = {}; @@ -391,11 +365,10 @@ export class AssetRepository implements IAssetRepository { switch (property) { case WithoutProperty.THUMBNAIL: { + relations = { jobStatus: true, files: true }; where = [ - { previewPath: IsNull(), isVisible: true }, - { previewPath: '', isVisible: true }, - { thumbnailPath: IsNull(), isVisible: true }, - { thumbnailPath: '', isVisible: true }, + { jobStatus: { previewAt: IsNull() }, isVisible: true }, + { jobStatus: { thumbnailAt: IsNull() }, isVisible: true }, { thumbhash: IsNull(), isVisible: true }, ]; break; @@ -429,7 +402,7 @@ export class AssetRepository implements IAssetRepository { }; where = { isVisible: true, - previewPath: Not(IsNull()), + jobStatus: { previewAt: Not(IsNull()) }, smartSearch: { embedding: IsNull(), }, @@ -439,10 +412,10 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.DUPLICATE: { where = { - previewPath: Not(IsNull()), isVisible: true, smartSearch: true, jobStatus: { + previewAt: Not(IsNull()), duplicatesDetectedAt: IsNull(), }, }; @@ -454,7 +427,9 @@ export class AssetRepository implements IAssetRepository { smartInfo: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, smartInfo: { tags: IsNull(), @@ -469,13 +444,13 @@ export class AssetRepository implements IAssetRepository { jobStatus: true, }; where = { - previewPath: Not(IsNull()), isVisible: true, faces: { assetId: IsNull(), personId: IsNull(), }, jobStatus: { + previewAt: Not(IsNull()), facesRecognizedAt: IsNull(), }, }; @@ -487,7 +462,9 @@ export class AssetRepository implements IAssetRepository { faces: true, }; where = { - previewPath: Not(IsNull()), + jobStatus: { + previewAt: Not(IsNull()), + }, isVisible: true, faces: { assetId: Not(IsNull()), @@ -520,43 +497,6 @@ export class AssetRepository implements IAssetRepository { }); } - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { - let where: FindOptionsWhere | FindOptionsWhere[] = {}; - - switch (property) { - case WithProperty.SIDECAR: { - where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; - break; - } - case WithProperty.IS_OFFLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding offline assets'); - } - where = [{ isOffline: true, libraryId: libraryId }]; - break; - } - - default: { - throw new Error(`Invalid getWith property: ${property}`); - } - } - - return paginate(this.repository, pagination, { - where, - order: { - // Ensures correct order when paginating - createdAt: 'ASC', - }, - }); - } - - getFirstAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { fileCreatedAt: 'DESC' }, - }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { return this.repository.findOne({ where: { albums: { id: albumId } }, @@ -583,7 +523,10 @@ export class AssetRepository implements IAssetRepository { } if (isTrashed !== undefined) { - builder.withDeleted().andWhere(`asset.deletedAt is not null`); + builder + .withDeleted() + .andWhere(`asset.deletedAt is not null`) + .andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); } const items = await builder.getRawMany(); @@ -602,14 +545,9 @@ export class AssetRepository implements IAssetRepository { return result; } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) - getRandom(ownerId: string, count: number): Promise { - const builder = this.getBuilder({ - userIds: [ownerId], - exifInfo: true, - }); - - return builder.orderBy('RANDOM()').limit(count).getMany(); + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER] }) + getRandom(userIds: string[], count: number): Promise { + return this.getBuilder({ userIds, exifInfo: true }).orderBy('RANDOM()').limit(count).getMany(); } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) @@ -704,10 +642,20 @@ export class AssetRepository implements IAssetRepository { private getBuilder(options: AssetBuilderOptions) { const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); + if (options.assetType !== undefined) { builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } + if (options.tagId) { + builder.innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: options.tagId }, + ); + } + let stackJoined = false; if (options.exifInfo !== false) { @@ -736,6 +684,13 @@ export class AssetRepository implements IAssetRepository { if (options.isTrashed !== undefined) { builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); + + if (options.isTrashed) { + // TODO: Temporarily inverted to support showing offline assets in the trash queries. + // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED + // and the offline screens should use a separate isOffline = true parameter in the timeline query. + builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); + } } if (options.isDuplicate !== undefined) { @@ -809,4 +764,14 @@ export class AssetRepository implements IAssetRepository { .withDeleted(); return builder.getMany(); } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); + } } diff --git a/server/src/repositories/audit.repository.ts b/server/src/repositories/audit.repository.ts index deb0d0f6f1..ac73c3a8b9 100644 --- a/server/src/repositories/audit.repository.ts +++ b/server/src/repositories/audit.repository.ts @@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { AuditEntity } from 'src/entities/audit.entity'; import { AuditSearch, IAuditRepository } from 'src/interfaces/audit.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { In, LessThan, MoreThan, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class AuditRepository implements IAuditRepository { constructor(@InjectRepository(AuditEntity) private repository: Repository) {} diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts new file mode 100644 index 0000000000..2ff5f53073 --- /dev/null +++ b/server/src/repositories/config.repository.spec.ts @@ -0,0 +1,267 @@ +import { ImmichTelemetry } from 'src/enum'; +import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository'; + +const getEnv = () => { + clearEnvCache(); + return new ConfigRepository().getEnv(); +}; + +const resetEnv = () => { + for (const env of [ + 'IMMICH_ENV', + 'IMMICH_WORKERS_INCLUDE', + 'IMMICH_WORKERS_EXCLUDE', + 'IMMICH_TRUSTED_PROXIES', + 'IMMICH_API_METRICS_PORT', + 'IMMICH_MICROSERVICES_METRICS_PORT', + 'IMMICH_TELEMETRY_INCLUDE', + 'IMMICH_TELEMETRY_EXCLUDE', + + 'DB_URL', + 'DB_HOSTNAME', + 'DB_PORT', + 'DB_USERNAME', + 'DB_PASSWORD', + 'DB_DATABASE_NAME', + 'DB_SKIP_MIGRATIONS', + 'DB_VECTOR_EXTENSION', + + 'REDIS_HOSTNAME', + 'REDIS_PORT', + 'REDIS_DBINDEX', + 'REDIS_USERNAME', + 'REDIS_PASSWORD', + 'REDIS_SOCKET', + 'REDIS_URL', + + 'NO_COLOR', + ]) { + delete process.env[env]; + } +}; + +const sentinelConfig = { + sentinels: [ + { + host: 'redis-sentinel-node-0', + port: 26_379, + }, + { + host: 'redis-sentinel-node-1', + port: 26_379, + }, + { + host: 'redis-sentinel-node-2', + port: 26_379, + }, + ], + name: 'redis-sentinel', +}; + +describe('getEnv', () => { + beforeEach(() => { + resetEnv(); + }); + + it('should use defaults', () => { + const config = getEnv(); + + expect(config).toMatchObject({ + host: undefined, + port: 2283, + environment: 'production', + configFile: undefined, + logLevel: undefined, + }); + }); + + describe('database', () => { + it('should use defaults', () => { + const { database } = getEnv(); + expect(database).toEqual({ + config: expect.objectContaining({ + type: 'postgres', + host: 'database', + port: 5432, + database: 'immich', + username: 'postgres', + password: 'postgres', + }), + skipMigrations: false, + vectorExtension: 'vectors', + }); + }); + + it('should allow skipping migrations', () => { + process.env.DB_SKIP_MIGRATIONS = 'true'; + const { database } = getEnv(); + expect(database).toMatchObject({ skipMigrations: true }); + }); + }); + + describe('redis', () => { + it('should use defaults', () => { + const { redis } = getEnv(); + expect(redis).toEqual({ + host: 'redis', + port: 6379, + db: 0, + username: undefined, + password: undefined, + path: undefined, + }); + }); + + it('should parse base64 encoded config, ignore other env', () => { + process.env.REDIS_URL = `ioredis://${Buffer.from(JSON.stringify(sentinelConfig)).toString('base64')}`; + process.env.REDIS_HOSTNAME = 'redis-host'; + process.env.REDIS_USERNAME = 'redis-user'; + process.env.REDIS_PASSWORD = 'redis-password'; + const { redis } = getEnv(); + expect(redis).toEqual(sentinelConfig); + }); + + it('should reject invalid json', () => { + process.env.REDIS_URL = `ioredis://${Buffer.from('{ "invalid json"').toString('base64')}`; + expect(() => getEnv()).toThrowError('Failed to decode redis options'); + }); + }); + + describe('noColor', () => { + beforeEach(() => { + delete process.env.NO_COLOR; + }); + + it('should default noColor to false', () => { + const { noColor } = getEnv(); + expect(noColor).toBe(false); + }); + + it('should map NO_COLOR=1 to true', () => { + process.env.NO_COLOR = '1'; + const { noColor } = getEnv(); + expect(noColor).toBe(true); + }); + + it('should map NO_COLOR=true to true', () => { + process.env.NO_COLOR = 'true'; + const { noColor } = getEnv(); + expect(noColor).toBe(true); + }); + }); + + describe('workers', () => { + it('should return default workers', () => { + const { workers } = getEnv(); + expect(workers).toEqual(['api', 'microservices']); + }); + + it('should return included workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should excluded workers from defaults', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api'; + const { workers } = getEnv(); + expect(workers).toEqual(['microservices']); + }); + + it('should exclude workers from include list', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should remove whitespace from included workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices'; + const { workers } = getEnv(); + expect(workers).toEqual(['api', 'microservices']); + }); + + it('should remove whitespace from excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices'; + const { workers } = getEnv(); + expect(workers).toEqual([]); + }); + + it('should remove whitespace from included and excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should throw error for invalid workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + expect(getEnv).toThrowError('Invalid worker(s) found: api,microservices,randomservice'); + }); + }); + + describe('network', () => { + it('should return default network options', () => { + const { network } = getEnv(); + expect(network).toEqual({ + trustedProxies: [], + }); + }); + + it('should parse trusted proxies', () => { + process.env.IMMICH_TRUSTED_PROXIES = '10.1.0.0,10.2.0.0, 169.254.0.0/16'; + const { network } = getEnv(); + expect(network).toEqual({ + trustedProxies: ['10.1.0.0', '10.2.0.0', '169.254.0.0/16'], + }); + }); + + it('should reject invalid trusted proxies', () => { + process.env.IMMICH_TRUSTED_PROXIES = '10.1'; + expect(() => getEnv()).toThrowError('Invalid environment variables: IMMICH_TRUSTED_PROXIES'); + }); + }); + + describe('telemetry', () => { + it('should have default values', () => { + const { telemetry } = getEnv(); + expect(telemetry).toEqual({ + apiPort: 8081, + microservicesPort: 8082, + metrics: new Set([]), + }); + }); + + it('should parse custom ports', () => { + process.env.IMMICH_API_METRICS_PORT = '2001'; + process.env.IMMICH_MICROSERVICES_METRICS_PORT = '2002'; + const { telemetry } = getEnv(); + expect(telemetry).toMatchObject({ + apiPort: 2001, + microservicesPort: 2002, + metrics: expect.any(Set), + }); + }); + + it('should run with telemetry enabled', () => { + process.env.IMMICH_TELEMETRY_INCLUDE = 'all'; + const { telemetry } = getEnv(); + expect(telemetry.metrics).toEqual(new Set(Object.values(ImmichTelemetry))); + }); + + it('should run with telemetry enabled and jobs disabled', () => { + process.env.IMMICH_TELEMETRY_INCLUDE = 'all'; + process.env.IMMICH_TELEMETRY_EXCLUDE = 'job'; + const { telemetry } = getEnv(); + expect(telemetry.metrics).toEqual( + new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO, ImmichTelemetry.REPO]), + ); + }); + + it('should run with specific telemetry metrics', () => { + process.env.IMMICH_TELEMETRY_INCLUDE = 'io, host, api'; + const { telemetry } = getEnv(); + expect(telemetry.metrics).toEqual(new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO])); + }); + }); +}); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts new file mode 100644 index 0000000000..76b0bb0c83 --- /dev/null +++ b/server/src/repositories/config.repository.ts @@ -0,0 +1,240 @@ +import { Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { Request, Response } from 'express'; +import { CLS_ID } from 'nestjs-cls'; +import { join, resolve } from 'node:path'; +import { citiesFile, excludePaths } from 'src/constants'; +import { Telemetry } from 'src/decorators'; +import { EnvDto } from 'src/dtos/env.dto'; +import { ImmichEnvironment, ImmichHeader, ImmichTelemetry, ImmichWorker } from 'src/enum'; +import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { QueueName } from 'src/interfaces/job.interface'; +import { setDifference } from 'src/utils/set'; + +const productionKeys = { + client: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', + server: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', +}; + +const stagingKeys = { + client: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', + server: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', +}; + +const WORKER_TYPES = new Set(Object.values(ImmichWorker)); +const TELEMETRY_TYPES = new Set(Object.values(ImmichTelemetry)); + +const asSet = (value: string | undefined, defaults: T[]) => { + const values = (value || '').replaceAll(/\s/g, '').split(',').filter(Boolean); + return new Set(values.length === 0 ? defaults : (values as T[])); +}; + +const getEnv = (): EnvData => { + const dto = plainToInstance(EnvDto, process.env); + const errors = validateSync(dto); + if (errors.length > 0) { + throw new Error( + `Invalid environment variables: ${errors.map((error) => `${error.property}=${error.value}`).join(', ')}`, + ); + } + + const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]); + const excludedWorkers = asSet(dto.IMMICH_WORKERS_EXCLUDE, []); + const workers = [...setDifference(includedWorkers, excludedWorkers)]; + for (const worker of workers) { + if (!WORKER_TYPES.has(worker)) { + throw new Error(`Invalid worker(s) found: ${workers.join(',')}`); + } + } + + const environment = dto.IMMICH_ENV || ImmichEnvironment.PRODUCTION; + const isProd = environment === ImmichEnvironment.PRODUCTION; + const buildFolder = dto.IMMICH_BUILD_DATA || '/build'; + const folders = { + // eslint-disable-next-line unicorn/prefer-module + dist: resolve(`${__dirname}/..`), + geodata: join(buildFolder, 'geodata'), + web: join(buildFolder, 'www'), + }; + + const databaseUrl = dto.DB_URL; + + let redisConfig = { + host: dto.REDIS_HOSTNAME || 'redis', + port: dto.REDIS_PORT || 6379, + db: dto.REDIS_DBINDEX || 0, + username: dto.REDIS_USERNAME || undefined, + password: dto.REDIS_PASSWORD || undefined, + path: dto.REDIS_SOCKET || undefined, + }; + + const redisUrl = dto.REDIS_URL; + if (redisUrl && redisUrl.startsWith('ioredis://')) { + try { + redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString()); + } catch (error) { + throw new Error(`Failed to decode redis options: ${error}`); + } + } + + const includedTelemetries = + dto.IMMICH_TELEMETRY_INCLUDE === 'all' + ? new Set(Object.values(ImmichTelemetry)) + : asSet(dto.IMMICH_TELEMETRY_INCLUDE, []); + + const excludedTelemetries = asSet(dto.IMMICH_TELEMETRY_EXCLUDE, []); + const telemetries = setDifference(includedTelemetries, excludedTelemetries); + for (const telemetry of telemetries) { + if (!TELEMETRY_TYPES.has(telemetry)) { + throw new Error(`Invalid telemetry found: ${telemetry}`); + } + } + + return { + host: dto.IMMICH_HOST, + port: dto.IMMICH_PORT || 2283, + environment, + configFile: dto.IMMICH_CONFIG_FILE, + logLevel: dto.IMMICH_LOG_LEVEL, + + buildMetadata: { + build: dto.IMMICH_BUILD, + buildUrl: dto.IMMICH_BUILD_URL, + buildImage: dto.IMMICH_BUILD_IMAGE, + buildImageUrl: dto.IMMICH_BUILD_IMAGE_URL, + repository: dto.IMMICH_REPOSITORY, + repositoryUrl: dto.IMMICH_REPOSITORY_URL, + sourceRef: dto.IMMICH_SOURCE_REF, + sourceCommit: dto.IMMICH_SOURCE_COMMIT, + sourceUrl: dto.IMMICH_SOURCE_URL, + thirdPartySourceUrl: dto.IMMICH_THIRD_PARTY_SOURCE_URL, + thirdPartyBugFeatureUrl: dto.IMMICH_THIRD_PARTY_BUG_FEATURE_URL, + thirdPartyDocumentationUrl: dto.IMMICH_THIRD_PARTY_DOCUMENTATION_URL, + thirdPartySupportUrl: dto.IMMICH_THIRD_PARTY_SUPPORT_URL, + }, + + bull: { + config: { + prefix: 'immich_bull', + connection: { ...redisConfig }, + defaultJobOptions: { + attempts: 3, + removeOnComplete: true, + removeOnFail: false, + }, + }, + queues: Object.values(QueueName).map((name) => ({ name })), + }, + + cls: { + config: { + middleware: { + mount: true, + generateId: true, + setup: (cls, req: Request, res: Response) => { + const headerValues = req.headers[ImmichHeader.CID]; + const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues; + const cid = headerValue || cls.get(CLS_ID); + cls.set(CLS_ID, cid); + res.header(ImmichHeader.CID, cid); + }, + }, + }, + }, + + database: { + config: { + type: 'postgres', + entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'], + migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], + subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'], + migrationsRun: false, + synchronize: false, + connectTimeoutMS: 10_000, // 10 seconds + parseInt8: true, + ...(databaseUrl + ? { connectionType: 'url', url: databaseUrl } + : { + 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', + }), + }, + + skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, + vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, + }, + + licensePublicKey: isProd ? productionKeys : stagingKeys, + + network: { + trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? [], + }, + + otel: { + metrics: { + hostMetrics: telemetries.has(ImmichTelemetry.HOST), + apiMetrics: { + enable: telemetries.has(ImmichTelemetry.API), + ignoreRoutes: excludePaths, + }, + }, + }, + + redis: redisConfig, + + resourcePaths: { + lockFile: join(buildFolder, 'build-lock.json'), + geodata: { + dateFile: join(folders.geodata, 'geodata-date.txt'), + admin1: join(folders.geodata, 'admin1CodesASCII.txt'), + admin2: join(folders.geodata, 'admin2Codes.txt'), + cities500: join(folders.geodata, citiesFile), + naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'), + }, + web: { + root: folders.web, + indexHtml: join(folders.web, 'index.html'), + }, + }, + + storage: { + ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS, + }, + + telemetry: { + apiPort: dto.IMMICH_API_METRICS_PORT || 8081, + microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082, + metrics: telemetries, + }, + + workers, + + noColor: !!dto.NO_COLOR, + }; +}; + +let cached: EnvData | undefined; + +@Injectable() +@Telemetry({ enabled: false }) +export class ConfigRepository implements IConfigRepository { + getEnv(): EnvData { + if (!cached) { + cached = getEnv(); + } + + return cached; + } +} + +export const clearEnvCache = () => (cached = undefined); diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index 72e75ef174..ee25609fec 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -3,9 +3,7 @@ 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'; -import { Instrumentation } from 'src/utils/instrumentation'; -@Instrumentation() @Injectable() export class CryptoRepository implements ICryptoRepository { randomUUID() { diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index fc9e76b0aa..b5e2edfdea 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -2,47 +2,61 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; import semver from 'semver'; -import { getVectorExtension } from 'src/database.config'; +import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, DatabaseLock, EXTENSION_NAMES, + ExtensionVersion, IDatabaseRepository, VectorExtension, VectorIndex, VectorUpdateResult, } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; -@Instrumentation() @Injectable() export class DatabaseRepository implements IDatabaseRepository { + private vectorExtension: VectorExtension; readonly asyncLock = new AsyncLock(); constructor( @InjectDataSource() private dataSource: DataSource, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IConfigRepository) configRepository: IConfigRepository, ) { + this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(DatabaseRepository.name); } - async getExtensionVersion(extension: DatabaseExtension): Promise { - const res = await this.dataSource.query(`SELECT extversion FROM pg_extension WHERE extname = $1`, [extension]); - return res[0]?.['extversion']; + 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; + } } - async getAvailableExtensionVersion(extension: DatabaseExtension): Promise { - const res = await this.dataSource.query( - ` - SELECT version FROM pg_available_extension_versions - WHERE name = $1 AND installed = false - ORDER BY version DESC`, + async getExtensionVersion(extension: DatabaseExtension): Promise { + const [res]: ExtensionVersion[] = await this.dataSource.query( + `SELECT default_version as "availableVersion", installed_version as "installedVersion" + FROM pg_available_extensions + WHERE name = $1`, [extension], ); - return res[0]?.['version']; + return res ?? { availableVersion: null, installedVersion: null }; + } + + getExtensionVersionRange(extension: VectorExtension): string { + return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE; } async getPostgresVersion(): Promise { @@ -50,37 +64,43 @@ export class DatabaseRepository implements IDatabaseRepository { return version; } + getPostgresVersionRange(): string { + return POSTGRES_VERSION_RANGE; + } + async createExtension(extension: DatabaseExtension): Promise { await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); } - async updateExtension(extension: DatabaseExtension, version?: string): Promise { - await this.dataSource.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`); - } - async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise { - const currentVersion = await this.getExtensionVersion(extension); - if (!currentVersion) { + const { availableVersion, installedVersion } = await this.getExtensionVersion(extension); + if (!installedVersion) { throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`); } + if (!availableVersion) { + throw new Error(`No available version for ${EXTENSION_NAMES[extension]} extension`); + } + targetVersion ??= availableVersion; + const isVectors = extension === DatabaseExtension.VECTORS; let restartRequired = false; await this.dataSource.manager.transaction(async (manager) => { await this.setSearchPath(manager); - const isSchemaUpgrade = targetVersion && semver.satisfies(targetVersion, '0.1.1 || 0.1.11'); + if (isVectors && installedVersion === '0.1.1') { + await this.setExtVersion(manager, DatabaseExtension.VECTORS, '0.1.11'); + } + + const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11'); if (isSchemaUpgrade && isVectors) { - await this.updateVectorsSchema(manager, currentVersion); + await this.updateVectorsSchema(manager); } - await manager.query(`ALTER EXTENSION ${extension} UPDATE${targetVersion ? ` TO '${targetVersion}'` : ''}`); + await manager.query(`ALTER EXTENSION ${extension} UPDATE TO '${targetVersion}'`); - if (!isSchemaUpgrade) { - return; - } - - if (isVectors) { + const diff = semver.diff(installedVersion, targetVersion); + if (isVectors && diff && ['minor', 'major'].includes(diff)) { await manager.query('SELECT pgvectors_upgrade()'); restartRequired = true; } else { @@ -96,40 +116,35 @@ export class DatabaseRepository implements IDatabaseRepository { try { await this.dataSource.query(`REINDEX INDEX ${index}`); } catch (error) { - if (getVectorExtension() === DatabaseExtension.VECTORS) { - this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); - const table = index === VectorIndex.CLIP ? 'smart_search' : 'face_search'; - 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} - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); - }); - } else { + if (this.vectorExtension !== DatabaseExtension.VECTORS) { throw error; } + this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); + + 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} + USING hnsw (embedding vector_cosine_ops) + WITH (ef_construction = 300, m = 16)`); + }); } } async shouldReindex(name: VectorIndex): Promise { - if (getVectorExtension() !== DatabaseExtension.VECTORS) { + if (this.vectorExtension !== DatabaseExtension.VECTORS) { return false; } try { - const res = await this.dataSource.query( - ` - SELECT idx_status - FROM pg_vector_index_stat - WHERE indexname = $1`, - [name], - ); + 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'; } catch (error) { const message: string = (error as any).message; @@ -146,19 +161,27 @@ export class DatabaseRepository implements IDatabaseRepository { await manager.query(`SET search_path TO "$user", public, vectors`); } - private async updateVectorsSchema(manager: EntityManager, currentVersion: string): Promise { - await manager.query('CREATE SCHEMA IF NOT EXISTS vectors'); - await manager.query(`UPDATE pg_catalog.pg_extension SET extversion = $1 WHERE extname = $2`, [ - currentVersion, - DatabaseExtension.VECTORS, - ]); - await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = $1', [ - DatabaseExtension.VECTORS, - ]); + 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 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; + if (!table) { + throw new Error(`Could not find table for index ${index}`); + } + return table; + } + + private async updateVectorsSchema(manager: EntityManager): 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', [ - DatabaseExtension.VECTORS, - ]); + await manager.query('UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = $1', [extension]); } private async getDimSize(table: string, column = 'embedding'): Promise { diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index aecc9d7239..4451ee09c5 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ModuleRef, Reflector } from '@nestjs/core'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -7,20 +7,35 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { ClassConstructor } from 'class-transformer'; +import _ from 'lodash'; import { Server, Socket } from 'socket.io'; +import { EventConfig } from 'src/decorators'; +import { MetadataKey } from 'src/enum'; import { + ArgsOf, ClientEventMap, EmitEvent, - EmitEventHandler, + EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; -import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; + +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; + +type Item = { + event: T; + handler: EmitHandler; + priority: number; + server: boolean; + label: string; +}; -@Instrumentation() @WebSocketGateway({ cors: true, path: '/api/socket.io', @@ -28,30 +43,67 @@ import { Instrumentation } from 'src/utils/instrumentation'; }) @Injectable() export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository { - private emitHandlers: Partial[]>> = {}; + private emitHandlers: EmitHandlers = {}; @WebSocketServer() private server?: Server; constructor( - private authService: AuthService, - private eventEmitter: EventEmitter2, + private moduleRef: ModuleRef, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); } + setup({ services }: { services: ClassConstructor[] }) { + const reflector = this.moduleRef.get(Reflector, { strict: false }); + const items: Item[] = []; + + // discovery + for (const Service of services) { + const instance = this.moduleRef.get(Service); + const ctx = Object.getPrototypeOf(instance); + for (const property of Object.getOwnPropertyNames(ctx)) { + const descriptor = Object.getOwnPropertyDescriptor(ctx, property); + if (!descriptor || descriptor.get || descriptor.set) { + continue; + } + + const handler = instance[property]; + if (typeof handler !== 'function') { + continue; + } + + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { + continue; + } + + items.push({ + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, + handler: handler.bind(instance), + label: `${Service.name}.${handler.name}`, + }); + } + } + + const handlers = _.orderBy(items, ['priority'], ['asc']); + + // register by priority + for (const handler of handlers) { + this.addHandler(handler); + } + } + afterInit(server: Server) { this.logger.log('Initialized websocket server'); - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; - } - - server.on(event, (data: unknown) => { + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -59,9 +111,16 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.authService.validate(client.request.headers, {}); + const auth = await this.moduleRef.get(AuthService).authenticate({ + headers: client.request.headers, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, + }); await client.join(auth.user.id); - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + if (auth.session) { + await client.join(auth.session.id); + } + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -74,29 +133,42 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitEventHandler): void { - const handlers: EmitEventHandler[] = this.emitHandlers[event] || []; - this.emitHandlers[event] = [...handlers, handler]; + private addHandler(item: EventItem): void { + const event = item.event; + + if (!this.emitHandlers[event]) { + this.emitHandlers[event] = []; + } + + this.emitHandlers[event].push(item); } - async emit(event: T, ...args: Parameters>): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + async emit(event: T, ...args: ArgsOf): Promise { + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } - clientSend(event: E, userId: string, data: ClientEventMap[E]) { - this.server?.to(userId).emit(event, data); + clientSend(event: T, room: string, ...data: ClientEventMap[T]) { + this.server?.to(room).emit(event, ...data); } - clientBroadcast(event: E, data: ClientEventMap[E]) { - this.server?.emit(event, data); + clientBroadcast(event: T, ...data: ClientEventMap[T]) { + this.server?.emit(event, ...data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 3be6b375a0..e487df503c 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; @@ -16,11 +17,12 @@ import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { INotificationRepository } from 'src/interfaces/notification.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.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 { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; @@ -29,7 +31,11 @@ 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 { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -37,6 +43,7 @@ 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 { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -48,11 +55,12 @@ 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 { MetricRepository } from 'src/repositories/metric.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'; @@ -61,7 +69,11 @@ import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.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'; export const repositories = [ { provide: IAccessRepository, useClass: AccessRepository }, @@ -70,6 +82,7 @@ export const repositories = [ { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, { provide: IAuditRepository, useClass: AuditRepository }, + { provide: IConfigRepository, useClass: ConfigRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, @@ -82,11 +95,12 @@ export const repositories = [ { provide: IMediaRepository, useClass: MediaRepository }, { provide: IMemoryRepository, useClass: MemoryRepository }, { provide: IMetadataRepository, useClass: MetadataRepository }, - { provide: IMetricRepository, useClass: MetricRepository }, { provide: IMoveRepository, useClass: MoveRepository }, { provide: INotificationRepository, useClass: NotificationRepository }, + { provide: IOAuthRepository, useClass: OAuthRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, + { provide: IProcessRepository, useClass: ProcessRepository }, { provide: ISearchRepository, useClass: SearchRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISessionRepository, useClass: SessionRepository }, @@ -95,5 +109,9 @@ export const repositories = [ { provide: IStorageRepository, useClass: StorageRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, + { provide: ITelemetryRepository, useClass: TelemetryRepository }, + { provide: ITrashRepository, useClass: TrashRepository }, { provide: IUserRepository, useClass: UserRepository }, + { provide: IVersionHistoryRepository, useClass: VersionHistoryRepository }, + { provide: IViewRepository, useClass: ViewRepository }, ]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index c17a602577..131dd770aa 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -5,8 +5,9 @@ import { SchedulerRegistry } from '@nestjs/schedule'; import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq'; import { CronJob, CronTime } from 'cron'; import { setTimeout } from 'node:timers/promises'; -import { bullConfig } from 'src/config'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { + IEntityJob, IJobRepository, JobCounts, JobItem, @@ -16,7 +17,6 @@ import { QueueStatus, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; export const JOBS_TO_QUEUE: Record = { // misc @@ -30,17 +30,21 @@ export const JOBS_TO_QUEUE: Record = { [JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK, [JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK, + // backups + [JobName.BACKUP_DATABASE]: QueueName.BACKUP_DATABASE, + // conversion [JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION, [JobName.VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION, // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, @@ -76,11 +80,12 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management - [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, - [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, - [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, // Notification @@ -91,9 +96,11 @@ export const JOBS_TO_QUEUE: Record = { // Version check [JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK, + + // Trash + [JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK, }; -@Instrumentation() @Injectable() export class JobRepository implements IJobRepository { private workers: Partial> = {}; @@ -101,14 +108,16 @@ export class JobRepository implements IJobRepository { constructor( private moduleReference: ModuleRef, private schedulerReqistry: SchedulerRegistry, + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(JobRepository.name); } addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise) { + const { bull } = this.configRepository.getEnv(); const workerHandler: Processor = async (job: Job) => handler(job as JobItem); - const workerOptions: WorkerOptions = { ...bullConfig, concurrency }; + const workerOptions: WorkerOptions = { ...bull.config, concurrency }; this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions); } @@ -141,14 +150,14 @@ export class JobRepository implements IJobRepository { job.setTime(new CronTime(expression)); } if (start !== undefined) { - start ? job.start() : job.stop(); + if (start) { + job.start(); + } else { + job.stop(); + } } } - deleteCronJob(name: string): void { - this.schedulerReqistry.deleteCronJob(name); - } - setConcurrency(queueName: QueueName, concurrency: number) { const worker = this.workers[queueName]; if (!worker) { @@ -245,6 +254,9 @@ export class JobRepository implements IJobRepository { private getJobOptions(item: JobItem): JobsOptions | null { switch (item.name) { + case JobName.NOTIFY_ALBUM_UPDATE: { + return { jobId: item.data.id, delay: item.data?.delay }; + } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { return { jobId: item.data.id }; } @@ -254,7 +266,6 @@ export class JobRepository implements IJobRepository { case JobName.QUEUE_FACIAL_RECOGNITION: { return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; } - default: { return null; } @@ -264,4 +275,20 @@ export class JobRepository implements IJobRepository { private getQueue(queue: QueueName): Queue { return this.moduleReference.get(getQueueToken(queue), { strict: false }); } + + public async removeJob(jobId: string, name: JobName): Promise { + const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId); + if (!existingJob) { + return; + } + try { + await existingJob.remove(); + } catch (error: any) { + if (error.message?.includes('Missing key for job')) { + return; + } + throw error; + } + return existingJob.data; + } } diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 963b0aaf73..1446395854 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -4,11 +4,9 @@ import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryEntity } from 'src/entities/library.entity'; import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { IsNull, Not } from 'typeorm'; import { Repository } from 'typeorm/repository/Repository.js'; -@Instrumentation() @Injectable() export class LibraryRepository implements ILibraryRepository { constructor(@InjectRepository(LibraryEntity) private repository: Repository) {} @@ -94,30 +92,6 @@ export class LibraryRepository implements ILibraryRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - async getAssetIds(libraryId: string, withDeleted = false): Promise { - const builder = this.repository - .createQueryBuilder('library') - .innerJoinAndSelect('library.assets', 'assets') - .where('library.id = :id', { id: libraryId }) - .select('assets.id'); - - if (withDeleted) { - builder.withDeleted(); - } - - // Return all asset paths for a given library - const rawResults = await builder.getRawMany(); - - const results: string[] = []; - - for (const rawPath of rawResults) { - results.push(rawPath.assets_id); - } - - return results; - } - private async save(library: Partial) { const { id } = await this.repository.save(library); return this.repository.findOneByOrFail({ id }); diff --git a/server/src/repositories/logger.repository.spec.ts b/server/src/repositories/logger.repository.spec.ts new file mode 100644 index 0000000000..dcb54ada7c --- /dev/null +++ b/server/src/repositories/logger.repository.spec.ts @@ -0,0 +1,40 @@ +import { ClsService } from 'nestjs-cls'; +import { ImmichWorker } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { LoggerRepository } from 'src/repositories/logger.repository'; +import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { Mocked } from 'vitest'; + +describe(LoggerRepository.name, () => { + let sut: LoggerRepository; + + let configMock: Mocked; + let clsMock: Mocked; + + beforeEach(() => { + configMock = newConfigRepositoryMock(); + clsMock = { + getId: vitest.fn(), + } as unknown as Mocked; + }); + + describe('formatContext', () => { + it('should use colors', () => { + configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + + sut = new LoggerRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); + + expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); + + it('should not use colors when noColor is true', () => { + configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + + sut = new LoggerRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); + + expect(sut['formatContext']('context')).toBe('[Api:context] '); + }); + }); +}); diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 1527965b49..4f1d3cac22 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -1,32 +1,50 @@ -import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; +import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; -import { LogLevel } from 'src/config'; +import { Telemetry } from 'src/decorators'; +import { LogLevel } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { LogColor } from 'src/utils/logger-colors'; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; +enum LogColor { + RED = 31, + GREEN = 32, + YELLOW = 33, + BLUE = 34, + MAGENTA_BRIGHT = 95, + CYAN_BRIGHT = 96, +} + @Injectable({ scope: Scope.TRANSIENT }) +@Telemetry({ enabled: false }) export class LoggerRepository extends ConsoleLogger implements ILoggerRepository { private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + private noColor: boolean; - constructor(private cls: ClsService) { + constructor( + private cls: ClsService, + @Inject(IConfigRepository) configRepository: IConfigRepository, + ) { super(LoggerRepository.name); + + const { noColor } = configRepository.getEnv(); + this.noColor = noColor; } private static appName?: string = undefined; setAppName(name: string): void { - LoggerRepository.appName = name; + LoggerRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); } isLevelEnabled(level: LogLevel) { return isLogLevelEnabled(level, LoggerRepository.logLevels); } - setLogLevel(level: LogLevel): void { - LoggerRepository.logLevels = LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)); + setLogLevel(level: LogLevel | false): void { + LoggerRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; } protected formatContext(context: string): string { @@ -44,6 +62,19 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository return ''; } - return LogColor.yellow(`[${prefix}]`) + ' '; + return this.colors.yellow(`[${prefix}]`) + ' '; + } + + private colors = { + red: (text: string) => this.withColor(text, LogColor.RED), + green: (text: string) => this.withColor(text, LogColor.GREEN), + yellow: (text: string) => this.withColor(text, LogColor.YELLOW), + blue: (text: string) => this.withColor(text, LogColor.BLUE), + magentaBright: (text: string) => this.withColor(text, LogColor.MAGENTA_BRIGHT), + cyanBright: (text: string) => this.withColor(text, LogColor.CYAN_BRIGHT), + }; + + private withColor(text: string, color: LogColor) { + return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; } } diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index b9404022ef..74b17ca6a7 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -12,11 +12,9 @@ import { ModelTask, ModelType, } from 'src/interfaces/machine-learning.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; const errorPrefix = 'Machine learning request'; -@Instrumentation() @Injectable() export class MachineLearningRepository implements IMachineLearningRepository { private async predict(url: string, payload: ModelPayload, config: MachineLearningRequest): Promise { diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 80b3fd7854..7ad94016e8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -4,11 +4,12 @@ import { getName } from 'i18n-iso-countries'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; -import { citiesFile, resourcePaths } from 'src/constants'; +import { citiesFile } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GeoPoint, @@ -19,11 +20,9 @@ import { } from 'src/interfaces/map.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { OptionalBetween } from 'src/utils/database'; -import { Instrumentation } from 'src/utils/instrumentation'; import { DataSource, In, IsNull, Not, QueryRunner, Repository } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; -@Instrumentation() @Injectable() export class MapRepository implements IMapRepository { constructor( @@ -32,6 +31,7 @@ export class MapRepository implements IMapRepository { @InjectRepository(NaturalEarthCountriesEntity) private naturalEarthCountriesRepository: Repository, @InjectDataSource() private dataSource: DataSource, + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -40,6 +40,7 @@ export class MapRepository implements IMapRepository { async init(): Promise { this.logger.log('Initializing metadata repository'); + const { resourcePaths } = this.configRepository.getEnv(); const geodataDate = await readFile(resourcePaths.geodata.dateFile, 'utf8'); // TODO move to service init @@ -110,21 +111,7 @@ export class MapRepository implements IMapRepository { })); } - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - - async reverseGeocode(point: GeoPoint): Promise { + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); const response = await this.geodataPlacesRepository @@ -159,7 +146,7 @@ export class MapRepository implements IMapRepository { `Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, ); - return null; + return { country: null, state: null, city: null }; } this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); @@ -181,6 +168,8 @@ export class MapRepository implements IMapRepository { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); + const { resourcePaths } = this.configRepository.getEnv(); + try { await queryRunner.startTransaction(); await queryRunner.manager.clear(NaturalEarthCountriesEntity); @@ -225,6 +214,7 @@ export class MapRepository implements IMapRepository { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); + const { resourcePaths } = this.configRepository.getEnv(); const admin1 = await this.loadAdmin(resourcePaths.geodata.admin1); const admin2 = await this.loadAdmin(resourcePaths.geodata.admin2); @@ -280,6 +270,7 @@ export class MapRepository implements IMapRepository { admin1Map: Map, admin2Map: Map, ) { + const { resourcePaths } = this.configRepository.getEnv(); await this.loadGeodataToTableFromFile( queryRunner, (lineSplit: string[]) => @@ -317,7 +308,7 @@ export class MapRepository implements IMapRepository { } const input = createReadStream(filePath); - const lineReader = readLine.createInterface({ input: input }); + const lineReader = readLine.createInterface({ input }); const adminMap = new Map(); for await (const line of lineReader) { diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 4003193ad4..d76d226f44 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,27 +1,40 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/config'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, - ThumbnailOptions, + ProbeOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); -@Instrumentation() +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Injectable() export class MediaRepository implements IMediaRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { @@ -44,19 +57,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - if (options.crop) { - pipeline.extract(options.crop); - } - - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -65,27 +71,61 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }) + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace); + + if (!options.raw) { + pipeline = pipeline.rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, formatLongName: results.format.format_long_name, - duration: results.format.duration || 0, - bitrate: results.format.bit_rate ?? 0, + duration: this.parseFloat(results.format.duration), + bitrate: this.parseInt(results.format.bit_rate), }, videoStreams: results.streams .filter((stream) => stream.codec_type === 'video') + .filter((stream) => !stream.disposition?.attached_pic) .map((stream) => ({ index: stream.index, - height: stream.height || 0, - width: stream.width || 0, + height: this.parseInt(stream.height), + width: this.parseInt(stream.width), codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -93,7 +133,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -101,7 +141,10 @@ export class MediaRepository implements IMediaRepository { transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise { if (!options.twoPass) { return new Promise((resolve, reject) => { - this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run(); + this.configureFfmpegCall(input, output, options) + .on('error', reject) + .on('end', () => resolve()) + .run(); }); } @@ -126,36 +169,54 @@ export class MediaRepository implements IMediaRepository { .on('error', reject) .on('end', () => handlePromiseError(fs.unlink(`${output}-0.log`), this.logger)) .on('end', () => handlePromiseError(fs.rm(`${output}-0.log.mbtree`, { force: true }), this.logger)) - .on('end', resolve) + .on('end', () => resolve()) .run(); }) .run(); }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = progress.currentFps ? Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000 : 0; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; + } + + private parseFloat(value: string | number | undefined): number { + return Number.parseFloat(value as string) || 0; } } diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index e9b4532fe9..3c2a1ae191 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -3,10 +3,8 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { MemoryEntity } from 'src/entities/memory.entity'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { DataSource, In, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class MemoryRepository implements IMemoryRepository { constructor( diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3ca088e2d7..81c1b35e15 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,21 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; -import { DummyValue, GenerateSql } from 'src/decorators'; -import { ExifEntity } from 'src/entities/exif.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class MetadataRepository implements IMetadataRepository { private exiftool = new ExifTool({ defaultVideosToUTC: true, backfillTimezones: true, inferTimezoneFromDatestamps: true, + inferTimezoneFromTimeStamp: true, useMWG: true, numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ @@ -25,10 +20,7 @@ export class MetadataRepository implements IMetadataRepository { writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], }); - constructor( - @InjectRepository(ExifEntity) private exifRepository: Repository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(MetadataRepository.name); } @@ -36,11 +28,11 @@ export class MetadataRepository implements IMetadataRepository { await this.exiftool.end(); } - readTags(path: string): Promise { + readTags(path: string): Promise { return this.exiftool.read(path).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); - return null; - }) as Promise; + return {}; + }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { @@ -54,106 +46,4 @@ export class MetadataRepository implements IMetadataRepository { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } - - @GenerateSql({ params: [DummyValue.UUID] }) - async getCountries(userId: string): Promise { - const entity = await this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.country IS NOT NULL') - .select('exif.country') - .distinctOn(['exif.country']) - .getMany(); - - return entity.map((e) => e.country ?? '').filter((c) => c !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getStates(userId: string, country: string | undefined): Promise { - let result: ExifEntity[] = []; - - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.state IS NOT NULL') - .select('exif.state') - .distinctOn(['exif.state']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - result = await query.getMany(); - - return result.map((entity) => entity.state ?? '').filter((s) => s !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) - async getCities(userId: string, country: string | undefined, state: string | undefined): Promise { - let result: ExifEntity[] = []; - - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.city IS NOT NULL') - .select('exif.city') - .distinctOn(['exif.city']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - if (state) { - query.andWhere('exif.state = :state', { state }); - } - - result = await query.getMany(); - - return result.map((entity) => entity.city ?? '').filter((c) => c !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraMakes(userId: string, model: string | undefined): Promise { - let result: ExifEntity[] = []; - - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.make IS NOT NULL') - .select('exif.make') - .distinctOn(['exif.make']); - - if (model) { - query.andWhere('exif.model = :model', { model }); - } - - result = await query.getMany(); - - return result.map((entity) => entity.make ?? '').filter((m) => m !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraModels(userId: string, make: string | undefined): Promise { - let result: ExifEntity[] = []; - - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) - .andWhere('exif.model IS NOT NULL') - .select('exif.model') - .distinctOn(['exif.model']); - - if (make) { - query.andWhere('exif.make = :make', { make }); - } - - result = await query.getMany(); - - return result.map((entity) => entity.model ?? '').filter((m) => m !== ''); - } } diff --git a/server/src/repositories/metric.repository.ts b/server/src/repositories/metric.repository.ts deleted file mode 100644 index 5948e92fa6..0000000000 --- a/server/src/repositories/metric.repository.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { MetricOptions } from '@opentelemetry/api'; -import { MetricService } from 'nestjs-otel'; -import { IMetricGroupRepository, IMetricRepository, MetricGroupOptions } from 'src/interfaces/metric.interface'; -import { apiMetrics, hostMetrics, jobMetrics, repoMetrics } from 'src/utils/instrumentation'; - -class MetricGroupRepository implements IMetricGroupRepository { - private enabled = false; - constructor(private metricService: MetricService) {} - - addToCounter(name: string, value: number, options?: MetricOptions): void { - if (this.enabled) { - this.metricService.getCounter(name, options).add(value); - } - } - - addToGauge(name: string, value: number, options?: MetricOptions): void { - if (this.enabled) { - this.metricService.getUpDownCounter(name, options).add(value); - } - } - - addToHistogram(name: string, value: number, options?: MetricOptions): void { - if (this.enabled) { - this.metricService.getHistogram(name, options).record(value); - } - } - - configure(options: MetricGroupOptions): this { - this.enabled = options.enabled; - return this; - } -} - -@Injectable() -export class MetricRepository implements IMetricRepository { - api: MetricGroupRepository; - host: MetricGroupRepository; - jobs: MetricGroupRepository; - repo: MetricGroupRepository; - - constructor(metricService: MetricService) { - this.api = new MetricGroupRepository(metricService).configure({ enabled: apiMetrics }); - this.host = new MetricGroupRepository(metricService).configure({ enabled: hostMetrics }); - this.jobs = new MetricGroupRepository(metricService).configure({ enabled: jobMetrics }); - this.repo = new MetricGroupRepository(metricService).configure({ enabled: repoMetrics }); - } -} diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index a8416ff0ac..16d9004014 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -1,12 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; import { IMoveRepository, MoveCreate } from 'src/interfaces/move.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class MoveRepository implements IMoveRepository { constructor(@InjectRepository(MoveEntity) private repository: Repository) {} diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index ef6c8c2f39..293a80576f 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -15,9 +15,7 @@ import { SendEmailResponse, SmtpOptions, } from 'src/interfaces/notification.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -@Instrumentation() @Injectable() export class NotificationRepository implements INotificationRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { @@ -33,10 +31,10 @@ export class NotificationRepository implements INotificationRepository { } } - renderEmail(request: EmailRenderRequest): { html: string; text: string } { + async renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }> { const component = this.render(request); - const html = render(component, { pretty: true }); - const text = render(component, { plainText: true }); + const html = await render(component, { pretty: true }); + const text = await render(component, { plainText: true }); return { html, text }; } diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts new file mode 100644 index 0000000000..ed038f0137 --- /dev/null +++ b/server/src/repositories/oauth.repository.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { custom, generators, Issuer } from 'openid-client'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IOAuthRepository, OAuthConfig, OAuthProfile } from 'src/interfaces/oauth.interface'; + +@Injectable() +export class OAuthRepository implements IOAuthRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(OAuthRepository.name); + } + + init() { + custom.setHttpOptionsDefaults({ timeout: 30_000 }); + } + + async authorize(config: OAuthConfig, redirectUrl: string) { + const client = await this.getClient(config); + return client.authorizationUrl({ + redirect_uri: redirectUrl, + scope: config.scope, + state: generators.state(), + }); + } + + async getLogoutEndpoint(config: OAuthConfig) { + const client = await this.getClient(config); + return client.issuer.metadata.end_session_endpoint; + } + + async getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise { + const client = await this.getClient(config); + const params = client.callbackParams(url); + try { + const tokens = await client.callback(redirectUrl, params, { state: params.state }); + return await client.userinfo(tokens.access_token || ''); + } catch (error: Error | any) { + if (error.message.includes('unexpected JWT alg received')) { + this.logger.warn( + [ + 'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.', + 'Or, that you have specified a signing key in your OAuth provider.', + ].join(' '), + ); + } + + throw error; + } + } + + private async getClient({ + issuerUrl, + clientId, + clientSecret, + profileSigningAlgorithm, + signingAlgorithm, + }: OAuthConfig) { + try { + const issuer = await Issuer.discover(issuerUrl); + return new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + response_types: ['code'], + userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, + id_token_signed_response_alg: signingAlgorithm, + }); + } catch (error: any | AggregateError) { + this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); + throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); + } + } +} diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index e0c8998dbf..6b11a4e31e 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { PartnerEntity } from 'src/entities/partner.entity'; import { IPartnerRepository, PartnerIds } from 'src/interfaces/partner.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { DeepPartial, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class PartnerRepository implements IPartnerRepository { constructor(@InjectRepository(PartnerEntity) private repository: Repository) {} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 876ed369f6..56116d7b3b 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,31 +1,36 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, + PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class PersonRepository implements IPersonRepository { constructor( + @InjectDataSource() private dataSource: DataSource, @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, + @InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, ) {} @@ -35,22 +40,35 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } - async deleteAll(): Promise { - await this.personRepository.clear(); - } + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder('asset_faces') + .delete() + .andWhere('sourceType = :sourceType', { sourceType }) + .execute(); - async deleteAllFaces(): Promise { - await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -167,14 +185,11 @@ export class PersonRepository implements IPersonRepository { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { const queryBuilder = this.personRepository .createQueryBuilder('person') - .leftJoin('person.faces', 'face') .where( 'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', { userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, ) - .groupBy('person.id') - .orderBy('COUNT(face.assetId)', 'DESC') - .limit(20); + .limit(1000); if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); @@ -182,6 +197,21 @@ export class PersonRepository implements IPersonRepository { return queryBuilder.getMany(); } + @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) + getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { + const queryBuilder = this.personRepository + .createQueryBuilder('person') + .select(['person.id', 'person.name']) + .distinctOn(['lower(person.name)']) + .where(`person.ownerId = :userId AND person.name != ''`, { userId }); + + if (!withHidden) { + queryBuilder.andWhere('person.isHidden = false'); + } + + return queryBuilder.getMany(); + } + @GenerateSql({ params: [DummyValue.UUID] }) async getStatistics(personId: string): Promise { const items = await this.assetFaceRepository @@ -198,30 +228,6 @@ export class PersonRepository implements IPersonRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - getAssets(personId: string): Promise { - return this.assetRepository.find({ - where: { - faces: { - personId, - }, - isVisible: true, - isArchived: false, - }, - relations: { - faces: { - person: true, - }, - exifInfo: true, - }, - order: { - fileCreatedAt: 'desc', - }, - // TODO: remove after either (1) pagination or (2) time bucket is implemented for this query - take: 1000, - }); - } - @GenerateSql({ params: [DummyValue.UUID] }) async getNumberOfPeople(userId: string): Promise { const items = await this.personRepository @@ -248,18 +254,49 @@ export class PersonRepository implements IPersonRepository { return result; } - create(entity: Partial): Promise { - return this.personRepository.save(entity); + create(person: Partial): Promise { + return this.save(person); } - async createFaces(entities: AssetFaceEntity[]): Promise { - const res = await this.assetFaceRepository.save(entities); - return res.map((row) => row.id); + async createAll(people: Partial[]): Promise { + const results = await this.personRepository.save(people); + return results.map((person) => person.id); } - async update(entity: Partial): Promise { - const { id } = await this.personRepository.save(entity); - return this.personRepository.findOneByOrFail({ id }); + async refreshFaces( + facesToAdd: Partial[], + faceIdsToRemove: string[], + embeddingsToAdd?: FaceSearchEntity[], + ): Promise { + const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); + if (facesToAdd.length > 0) { + const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); + query.addCommonTableExpression(insertCte, 'added'); + } + + if (faceIdsToRemove.length > 0) { + const deleteCte = this.assetFaceRepository + .createQueryBuilder() + .delete() + .where('id = any(:faceIdsToRemove)', { faceIdsToRemove }); + query.addCommonTableExpression(deleteCte, 'deleted'); + } + + if (embeddingsToAdd?.length) { + const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); + query.addCommonTableExpression(embeddingCte, 'embeddings'); + query.getQuery(); // typeorm mixes up parameters without this + } + + await query.execute(); + } + + async update(person: Partial): Promise { + return this.save(person); + } + + async updateAll(people: Partial[]): Promise { + await this.personRepository.save(people); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @@ -281,4 +318,18 @@ export class PersonRepository implements IPersonRepository { .getRawOne(); return result?.latestDate; } + + private async save(person: Partial): Promise { + const { id } = await this.personRepository.save(person); + return this.personRepository.findOneByOrFail({ id }); + } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/repositories/process.repository.ts b/server/src/repositories/process.repository.ts new file mode 100644 index 0000000000..99ec51037c --- /dev/null +++ b/server/src/repositories/process.repository.ts @@ -0,0 +1,16 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IProcessRepository } from 'src/interfaces/process.interface'; +import { StorageRepository } from 'src/repositories/storage.repository'; + +@Injectable() +export class ProcessRepository implements IProcessRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(StorageRepository.name); + } + + spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { + return spawn(command, args, options); + } +} diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index a4c7edab91..b5969beecb 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,13 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { getVectorExtension } from 'src/database.config'; +import { randomUUID } from 'node:crypto'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { AssetType, PaginationMode } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, @@ -20,26 +23,27 @@ import { SmartSearchOptions, } from 'src/interfaces/search.interface'; import { asVector, searchAssetBuilder } from 'src/utils/database'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { getCLIPModelInfo } from 'src/utils/misc'; -import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; -import { Repository, SelectQueryBuilder } from 'typeorm'; +import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class SearchRepository implements ISearchRepository { + private vectorExtension: VectorExtension; private faceColumns: string[]; private assetsByCityQuery: string; constructor( @InjectRepository(SmartInfoEntity) private repository: Repository, @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IConfigRepository) configRepository: IConfigRepository, ) { + this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(SearchRepository.name); this.faceColumns = this.assetFaceRepository.manager.connection .getMetadata(AssetFaceEntity) @@ -55,35 +59,22 @@ export class SearchRepository implements ISearchRepository { ' INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city'; } - async init(modelName: string): Promise { - const { dimSize } = getCLIPModelInfo(modelName); - const curDimSize = await this.getDimSize(); - this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`); - - if (dimSize != curDimSize) { - this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`); - await this.updateDimSize(dimSize); - } - } - @GenerateSql({ params: [ { page: 1, size: 100 }, { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, @@ -91,12 +82,33 @@ export class SearchRepository implements ISearchRepository { }); } - private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { - return builder - .select(`${builder.alias}."assetId"`) - .where(`${builder.alias}."personId" IN (:...personIds)`, { personIds }) - .groupBy(`${builder.alias}."assetId"`) - .having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length }); + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; } @GenerateSql({ @@ -114,21 +126,12 @@ export class SearchRepository implements ISearchRepository { }) async searchSmart( pagination: SearchPaginationOptions, - { embedding, userIds, personIds, ...options }: SmartSearchOptions, + { embedding, userIds, ...options }: SmartSearchOptions, ): Paginated { let results: PaginationResult = { items: [], hasNextPage: false }; await this.assetRepository.manager.transaction(async (manager) => { let builder = manager.createQueryBuilder(AssetEntity, 'asset'); - - if (personIds?.length) { - const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face'); - const cte = this.createPersonFilter(assetFaceBuilder, personIds); - builder - .addCommonTableExpression(cte, 'asset_face_ids') - .innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id'); - } - builder = searchAssetBuilder(builder, options); builder .innerJoin('asset.smartSearch', 'search') @@ -300,32 +303,7 @@ export class SearchRepository implements ISearchRepository { ); } - private async updateDimSize(dimSize: number): Promise { - if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { - throw new Error(`Invalid CLIP dimension size: ${dimSize}`); - } - - const curDimSize = await this.getDimSize(); - if (curDimSize === dimSize) { - return; - } - - this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); - - await this.smartSearchRepository.manager.transaction(async (manager) => { - await manager.clear(SmartSearchEntity); - await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); - await manager.query(`REINDEX INDEX clip_index`); - }); - - this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`); - } - - deleteAllSearchEmbeddings(): Promise { - return this.smartSearchRepository.clear(); - } - - private async getDimSize(): Promise { + async getDimensionSize(): Promise { const res = await this.smartSearchRepository.manager.query(` SELECT atttypmod as dimsize FROM pg_attribute f @@ -342,8 +320,111 @@ export class SearchRepository implements ISearchRepository { return dimSize; } + setDimensionSize(dimSize: number): Promise { + if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { + throw new Error(`Invalid CLIP dimension size: ${dimSize}`); + } + + return this.smartSearchRepository.manager.transaction(async (manager) => { + await manager.clear(SmartSearchEntity); + await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); + await manager.query(`REINDEX INDEX clip_index`); + }); + } + + async deleteAllSearchEmbeddings(): Promise { + return this.smartSearchRepository.clear(); + } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + async getCountries(userIds: string[]): Promise { + const results = await this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.country', 'country') + .distinctOn(['exif.country']) + .getRawMany<{ country: string }>(); + + return results.map(({ country }) => country).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getStates(userIds: string[], country: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.state', 'state') + .distinctOn(['exif.state']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + const result = await query.getRawMany<{ state: string }>(); + + return result.map(({ state }) => state).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.city', 'city') + .distinctOn(['exif.city']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + if (state) { + query.andWhere('exif.state = :state', { state }); + } + + const results = await query.getRawMany<{ city: string }>(); + + return results.map(({ city }) => city).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraMakes(userIds: string[], model: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.make', 'make') + .distinctOn(['exif.make']); + + if (model) { + query.andWhere('exif.model = :model', { model }); + } + + const results = await query.getRawMany<{ make: string }>(); + return results.map(({ make }) => make).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraModels(userIds: string[], make: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.model', 'model') + .distinctOn(['exif.model']); + + if (make) { + query.andWhere('exif.make = :make', { make }); + } + + const results = await query.getRawMany<{ model: string }>(); + return results.map(({ model }) => model).filter((item) => item !== ''); + } + private getRuntimeConfig(numResults?: number): string { - if (getVectorExtension() === DatabaseExtension.VECTOR) { + if (this.vectorExtension === DatabaseExtension.VECTOR) { return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall } diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index f74eb7dd0d..b4a4652871 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,10 +4,9 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { resourcePaths } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; const exec = promisify(execCallback); const maybeFirstLine = async (command: string): Promise => { @@ -34,10 +33,12 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => { return item?.version; }; -@Instrumentation() @Injectable() export class ServerInfoRepository implements IServerInfoRepository { - constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + constructor( + @Inject(IConfigRepository) private configRepository: IConfigRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { this.logger.setContext(ServerInfoRepository.name); } @@ -56,6 +57,8 @@ export class ServerInfoRepository implements IServerInfoRepository { } async getBuildVersions(): Promise { + const { nodeVersion, resourcePaths } = this.configRepository.getEnv(); + const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ maybeFirstLine('node --version'), maybeFirstLine('ffmpeg -version'), @@ -67,7 +70,7 @@ export class ServerInfoRepository implements IServerInfoRepository { .catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`)); return { - nodejs: nodejsOutput || process.env.NODE_VERSION || '', + nodejs: nodejsOutput || nodeVersion || '', exiftool: await exiftool.version(), ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index a4b55a19d7..3a0af1ef69 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SessionEntity } from 'src/entities/session.entity'; import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { LessThanOrEqual, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class SessionRepository implements ISessionRepository { constructor(@InjectRepository(SessionEntity) private repository: Repository) {} diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 48dbb3ab90..1dfde99a75 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class SharedLinkRepository implements ISharedLinkRepository { constructor(@InjectRepository(SharedLinkEntity) private repository: Repository) {} diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index 46cc14e713..ae1a7f70d4 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -1,21 +1,118 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface'; +import { DataSource, In, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class StackRepository implements IStackRepository { - constructor(@InjectRepository(StackEntity) private repository: Repository) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @InjectRepository(StackEntity) private repository: Repository, + ) {} - create(entity: Partial) { - return this.save(entity); + search(query: StackSearch): Promise { + return this.repository.find({ + where: { + ownerId: query.ownerId, + primaryAssetId: query.primaryAssetId, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + } + + async create(entity: { ownerId: string; assetIds: string[] }): Promise { + return this.dataSource.manager.transaction(async (manager) => { + const stackRepository = manager.getRepository(StackEntity); + + const stacks = await stackRepository.find({ + where: { + ownerId: entity.ownerId, + primaryAssetId: In(entity.assetIds), + }, + select: { + id: true, + assets: { + id: true, + }, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + + const assetIds = new Set(entity.assetIds); + + // children + for (const stack of stacks) { + for (const asset of stack.assets) { + assetIds.add(asset.id); + } + } + + if (stacks.length > 0) { + await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) }); + } + + const { id } = await stackRepository.save({ + ownerId: entity.ownerId, + primaryAssetId: entity.assetIds[0], + assets: [...assetIds].map((id) => ({ id }) as AssetEntity), + }); + + return stackRepository.findOneOrFail({ + where: { + id, + }, + relations: { + assets: { + exifInfo: true, + }, + }, + }); + }); } async delete(id: string): Promise { + const stack = await this.getById(id); + if (!stack) { + return; + } + + const assetIds = stack.assets.map(({ id }) => id); + await this.repository.delete(id); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); + } + + async deleteAll(ids: string[]): Promise { + const assetIds = []; + for (const id of ids) { + const stack = await this.getById(id); + if (!stack) { + continue; + } + + assetIds.push(...stack.assets.map(({ id }) => id)); + } + + await this.repository.delete(ids); + + // Update assets updatedAt + await this.dataSource.manager.update(AssetEntity, assetIds, { + updatedAt: new Date(), + }); } update(entity: Partial) { @@ -28,8 +125,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } @@ -41,8 +144,14 @@ export class StackRepository implements IStackRepository { id, }, relations: { - primaryAsset: true, - assets: true, + assets: { + exifInfo: true, + }, + }, + order: { + assets: { + fileCreatedAt: 'ASC', + }, }, }); } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 0d0be5c062..e4c0c68451 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -2,10 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; -import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; +import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { CrawlOptionsDto } from 'src/dtos/library.dto'; +import { Writable } from 'node:stream'; +import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DiskUsage, @@ -14,16 +15,18 @@ import { ImmichZipStream, WatchEvents, } from 'src/interfaces/storage.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { mimeTypes } from 'src/utils/mime-types'; -@Instrumentation() @Injectable() export class StorageRepository implements IStorageRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(StorageRepository.name); } + realpath(filepath: string) { + return fs.realpath(filepath); + } + readdir(folder: string): Promise { return fs.readdir(folder); } @@ -36,8 +39,20 @@ export class StorageRepository implements IStorageRepository { return fs.stat(filepath); } - writeFile(filepath: string, buffer: Buffer) { - return fs.writeFile(filepath, buffer); + createFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'wx' }); + } + + createWriteStream(filepath: string): Writable { + return createWriteStream(filepath, { flags: 'w' }); + } + + createOrOverwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'w' }); + } + + overwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { @@ -52,7 +67,7 @@ export class StorageRepository implements IStorageRepository { const archive = archiver('zip', { store: true }); const addFile = (input: string, filename: string) => { - archive.file(input, { name: filename }); + archive.file(input, { name: filename, mode: 0o644 }); }; const finalize = () => archive.finalize(); @@ -144,7 +159,9 @@ export class StorageRepository implements IStorageRepository { return Promise.resolve([]); } - return glob(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + return glob(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -153,14 +170,16 @@ export class StorageRepository implements IStorageRepository { }); } - async *walk(crawlOptions: CrawlOptionsDto): AsyncGenerator { - const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; + async *walk(walkOptions: WalkOptionsDto): AsyncGenerator { + const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions; if (pathsToCrawl.length === 0) { async function* emptyGenerator() {} return emptyGenerator(); } - const stream = globStream(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + const stream = globStream(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -168,8 +187,17 @@ export class StorageRepository implements IStorageRepository { ignore: exclusionPatterns, }); + let batch: string[] = []; for await (const value of stream) { - yield value as string; + batch.push(value.toString()); + if (batch.length === walkOptions.take) { + yield batch; + batch = []; + } + } + + if (batch.length > 0) { + yield batch; } } @@ -185,10 +213,9 @@ export class StorageRepository implements IStorageRepository { return () => watcher.close(); } - private asGlob(pathsToCrawl: string[]): string { - const escapedPaths = pathsToCrawl.map((path) => escapePath(path)); - const base = escapedPaths.length === 1 ? escapedPaths[0] : `{${escapedPaths.join(',')}}`; + private asGlob(pathToCrawl: string): string { + const escapedPath = escapePath(pathToCrawl); const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; - return `${base}/**/${extensions}`; + return `${escapedPath}/**/${extensions}`; } } diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index d4e58bf74a..1c6aaf0517 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { readFile } from 'node:fs/promises'; import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class SystemMetadataRepository implements ISystemMetadataRepository { constructor( diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 788b976357..df5f7e6e42 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,33 +1,81 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; -@Instrumentation() @Injectable() export class TagRepository implements ITagRepository { constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } - getById(userId: string, id: string): Promise { - return this.repository.findOne({ - where: { - id, - userId, - }, - relations: { - user: true, - }, + get(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + getByValue(userId: string, value: string): Promise { + return this.repository.findOne({ where: { userId, value } }); + } + + 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'); + } + + // update closure table + await manager.query( + `INSERT INTO tags_closure (id_ancestor, id_descendant) + VALUES ($1, $1) + ON CONFLICT DO NOTHING;`, + [id], + ); + + 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], + ); + } + + return manager.findOneOrFail(TagEntity, { where: { id } }); }); } - getAll(userId: string): Promise { - return this.repository.find({ where: { userId } }); + async getAll(userId: string): Promise { + const tags = await this.repository.find({ + where: { userId }, + order: { + value: 'ASC', + }, + }); + + return tags; } create(tag: Partial): Promise { @@ -38,89 +86,127 @@ export class TagRepository implements ITagRepository { return this.save(tag); } - async remove(tag: TagEntity): Promise { - await this.repository.remove(tag); + async delete(id: string): Promise { + await this.repository.delete(id); } - async getAssets(userId: string, tagId: string): Promise { - return this.assetRepository.find({ - where: { - tags: { - userId, - id: tagId, - }, - }, - relations: { - exifInfo: true, - tags: true, - faces: { - person: true, - }, - }, - order: { - createdAt: 'ASC', - }, - }); - } - - async addAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags.push({ id } as TagEntity); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + @ChunkedSet({ paramIndex: 1 }) + 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 }>(); + + return new Set(results.map(({ assetId }) => assetId)); } - async removeAssets(userId: string, id: string, assetIds: string[]): Promise { - for (const assetId of assetIds) { - const asset = await this.assetRepository.findOneOrFail({ - where: { - ownerId: userId, - id: assetId, - }, - relations: { - tags: true, - }, - }); - asset.tags = asset.tags.filter((tag) => tag.id !== id); - await this.assetRepository.save(asset); + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + async addAssetIds(tagId: string, assetIds: string[]): Promise { + if (assetIds.length === 0) { + return; } + + await this.dataSource.manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); } - hasAsset(userId: string, tagId: string, assetId: string): Promise { - return this.repository.exists({ - where: { - id: tagId, - userId, - assets: { - id: assetId, - }, - }, - relations: { - assets: true, - }, + @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(); + } + + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] }) + @Chunked() + async upsertAssetIds(items: AssetTagItem[]): Promise { + if (items.length === 0) { + return []; + } + + const { identifiers } = await this.dataSource + .createQueryBuilder() + .insert() + .into('tag_asset', ['assetsId', 'tagsId']) + .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId }))) + .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(); + + if (tagIds.length === 0) { + return; + } + + await manager + .createQueryBuilder() + .insert() + .into('tag_asset', ['tagsId', 'assetsId']) + .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId }))) + .execute(); }); } - hasName(userId: string, name: string): Promise { - return this.repository.exists({ - where: { - name, - userId, - }, + 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(); + + 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`); + } }); } - private async save(tag: Partial): Promise { - const { id } = await this.repository.save(tag); - return this.repository.findOneOrFail({ where: { id }, relations: { user: true } }); + 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/telemetry.repository.ts b/server/src/repositories/telemetry.repository.ts new file mode 100644 index 0000000000..2510460967 --- /dev/null +++ b/server/src/repositories/telemetry.repository.ts @@ -0,0 +1,167 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { MetricOptions } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; +import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; +import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; +import { NodeSDK, contextBase, metrics, resources } from '@opentelemetry/sdk-node'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { ClassConstructor } from 'class-transformer'; +import { snakeCase, startCase } from 'lodash'; +import { MetricService } from 'nestjs-otel'; +import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; +import { serverVersion } from 'src/constants'; +import { ImmichTelemetry, MetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface'; + +class MetricGroupRepository implements IMetricGroupRepository { + private enabled = false; + + constructor(private metricService: MetricService) {} + + addToCounter(name: string, value: number, options?: MetricOptions): void { + if (this.enabled) { + this.metricService.getCounter(name, options).add(value); + } + } + + addToGauge(name: string, value: number, options?: MetricOptions): void { + if (this.enabled) { + this.metricService.getUpDownCounter(name, options).add(value); + } + } + + addToHistogram(name: string, value: number, options?: MetricOptions): void { + if (this.enabled) { + this.metricService.getHistogram(name, options).record(value); + } + } + + configure(options: MetricGroupOptions): this { + this.enabled = options.enabled; + return this; + } +} + +const aggregation = new metrics.ExplicitBucketHistogramAggregation( + [0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000], + true, +); + +let instance: NodeSDK | undefined; + +export const bootstrapTelemetry = (port: number) => { + if (instance) { + throw new Error('OpenTelemetry SDK already started'); + } + instance = new NodeSDK({ + resource: new resources.Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: `immich`, + [SemanticResourceAttributes.SERVICE_VERSION]: serverVersion.toString(), + }), + metricReader: new PrometheusExporter({ port }), + contextManager: new AsyncLocalStorageContextManager(), + instrumentations: [ + new HttpInstrumentation(), + new IORedisInstrumentation(), + new NestInstrumentation(), + new PgInstrumentation(), + ], + views: [new metrics.View({ aggregation, instrumentName: '*', instrumentUnit: 'ms' })], + }); + + instance.start(); +}; + +export const teardownTelemetry = async () => { + if (instance) { + await instance.shutdown(); + instance = undefined; + } +}; + +@Injectable() +export class TelemetryRepository implements ITelemetryRepository { + api: MetricGroupRepository; + host: MetricGroupRepository; + jobs: MetricGroupRepository; + repo: MetricGroupRepository; + + constructor( + private metricService: MetricService, + private reflect: Reflector, + @Inject(IConfigRepository) private configRepository: IConfigRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + const { telemetry } = this.configRepository.getEnv(); + const { metrics } = telemetry; + + this.api = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.API) }); + this.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.HOST) }); + this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.JOB) }); + this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.REPO) }); + } + + setup({ repositories }: { repositories: ClassConstructor[] }) { + const { telemetry } = this.configRepository.getEnv(); + const { metrics } = telemetry; + if (!metrics.has(ImmichTelemetry.REPO)) { + return; + } + + for (const Repository of repositories) { + const isEnabled = this.reflect.get(MetadataKey.TELEMETRY_ENABLED, Repository) ?? true; + if (!isEnabled) { + this.logger.debug(`Telemetry disabled for ${Repository.name}`); + continue; + } + + this.wrap(Repository); + } + } + + private wrap(Repository: ClassConstructor) { + const className = Repository.name; + const descriptors = Object.getOwnPropertyDescriptors(Repository.prototype); + const unit = 'ms'; + + for (const [propName, descriptor] of Object.entries(descriptors)) { + const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor'; + if (!isMethod) { + continue; + } + + const method = descriptor.value; + const propertyName = snakeCase(String(propName)); + const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${propertyName}.duration`; + + const histogram = this.metricService.getHistogram(metricName, { + prefix: 'immich', + description: `The elapsed time in ${unit} for the ${startCase(className)} to ${propertyName.toLowerCase()}`, + unit, + valueType: contextBase.ValueType.DOUBLE, + }); + + descriptor.value = function (...args: any[]) { + const start = performance.now(); + const result = method.apply(this, args); + + void Promise.resolve(result) + .then(() => histogram.record(performance.now() - start, {})) + .catch(() => { + // noop + }); + + return result; + }; + + copyMetadataFromFunctionToFunction(method, descriptor.value); + Object.defineProperty(Repository.prototype, propName, descriptor); + } + } +} diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts new file mode 100644 index 0000000000..d24f4f709a --- /dev/null +++ b/server/src/repositories/trash.repository.ts @@ -0,0 +1,52 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetStatus } from 'src/enum'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; +import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; +import { In, Repository } from 'typeorm'; + +export class TrashRepository implements ITrashRepository { + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + + async getDeletedIds(pagination: PaginationOptions): Paginated { + const { hasNextPage, items } = await paginatedBuilder( + this.assetRepository + .createQueryBuilder('asset') + .select('asset.id') + .where({ status: AssetStatus.DELETED }) + .withDeleted(), + pagination, + ); + + return { + hasNextPage, + items: items.map((asset) => asset.id), + }; + } + + async restore(userId: string): Promise { + const result = await this.assetRepository.update( + { ownerId: userId, status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); + + return result.affected || 0; + } + + async empty(userId: string): Promise { + const result = await this.assetRepository.update( + { ownerId: userId, status: AssetStatus.TRASHED }, + { status: AssetStatus.DELETED }, + ); + + return result.affected || 0; + } + + async restoreAll(ids: string[]): Promise { + const result = await this.assetRepository.update( + { id: In(ids), status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); + return result.affected ?? 0; + } +} diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index c64d5a3655..6ac8536ef8 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -10,10 +10,8 @@ import { UserListFilter, UserStatsQueryResponse, } from 'src/interfaces/user.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; import { IsNull, Not, Repository } from 'typeorm'; -@Instrumentation() @Injectable() export class UserRepository implements IUserRepository { constructor( diff --git a/server/src/repositories/version-history.repository.ts b/server/src/repositories/version-history.repository.ts new file mode 100644 index 0000000000..e32ceaf4e9 --- /dev/null +++ b/server/src/repositories/version-history.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { VersionHistoryEntity } from 'src/entities/version-history.entity'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { Repository } from 'typeorm'; + +@Injectable() +export class VersionHistoryRepository implements IVersionHistoryRepository { + constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository) {} + + async getAll(): Promise { + return this.repository.find({ order: { createdAt: 'DESC' } }); + } + + async getLatest(): Promise { + const results = await this.repository.find({ order: { createdAt: 'DESC' }, take: 1 }); + return results[0] || null; + } + + create(version: Omit): Promise { + return this.repository.save(version); + } +} diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts new file mode 100644 index 0000000000..3645e3638a --- /dev/null +++ b/server/src/repositories/view-repository.ts @@ -0,0 +1,48 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Brackets, Repository } from 'typeorm'; + +export class ViewRepository implements IViewRepository { + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + + async getUniqueOriginalPaths(userId: string): Promise { + const results = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') + .getRawMany(); + + return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); + const assets = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .leftJoinAndSelect('asset.exifInfo', 'exifInfo') + .andWhere( + new Brackets((qb) => { + qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( + 'asset.originalPath NOT LIKE :notLikePath', + { notLikePath: `%${normalizedPath}/%/%` }, + ); + }), + ) + .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') + .getMany(); + + return assets; + } +} diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 30720b6c1f..f9a8e6ce47 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -4,20 +4,18 @@ import { IActivityRepository } from 'src/interfaces/activity.interface'; import { ActivityService } from 'src/services/activity.service'; import { activityStub } from 'test/fixtures/activity.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(ActivityService.name, () => { let sut: ActivityService; + let accessMock: IAccessRepositoryMock; let activityMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - activityMock = newActivityRepositoryMock(); - - sut = new ActivityService(accessMock, activityMock); + ({ sut, accessMock, activityMock } = newTestService(ActivityService)); }); it('should work', () => { diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 7589fb8ccc..fce104ecbd 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,5 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { Injectable } from '@nestjs/common'; import { ActivityCreateDto, ActivityDto, @@ -13,23 +12,14 @@ import { } from 'src/dtos/activity.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ActivityEntity } from 'src/entities/activity.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class ActivityService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IActivityRepository) private repository: IActivityRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - +export class ActivityService extends BaseService { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); - const activities = await this.repository.search({ + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + const activities = await this.activityRepository.search({ userId: dto.userId, albumId: dto.albumId, assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, @@ -40,12 +30,12 @@ export class ActivityService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, dto.albumId); - return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) }; } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await this.access.requirePermission(auth, Permission.ACTIVITY_CREATE, dto.albumId); + await this.requireAccess({ auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -58,7 +48,7 @@ export class ActivityService { if (dto.type === ReactionType.LIKE) { delete dto.comment; - [activity] = await this.repository.search({ + [activity] = await this.activityRepository.search({ ...common, // `null` will search for an album like assetId: dto.assetId ?? null, @@ -68,7 +58,7 @@ export class ActivityService { } if (!activity) { - activity = await this.repository.create({ + activity = await this.activityRepository.create({ ...common, isLiked: dto.type === ReactionType.LIKE, comment: dto.comment, @@ -79,7 +69,7 @@ export class ActivityService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ACTIVITY_DELETE, id); - await this.repository.delete(id); + await this.requireAccess({ auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); + await this.activityRepository.delete(id); } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 4d6aca7a8b..12c93ee127 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,58 +1,46 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { AlbumUserRole } from 'src/entities/album-user.entity'; +import { AlbumUserRole } from 'src/enum'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(AlbumService.name, () => { let sut: AlbumService; + let accessMock: IAccessRepositoryMock; let albumMock: Mocked; - let assetMock: Mocked; + let albumUserMock: Mocked; let eventMock: Mocked; let userMock: Mocked; - let albumUserMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - userMock = newUserRepositoryMock(); - albumUserMock = newAlbumUserRepositoryMock(); - - sut = new AlbumService(accessMock, albumMock, assetMock, eventMock, userMock, albumUserMock); + ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); }); it('should work', () => { expect(sut).toBeDefined(); }); - describe('getCount', () => { + describe('getStatistics', () => { it('should get the album count', async () => { - albumMock.getOwned.mockResolvedValue([]), - albumMock.getShared.mockResolvedValue([]), - albumMock.getNotShared.mockResolvedValue([]), - await expect(sut.getCount(authStub.admin)).resolves.toEqual({ - owned: 0, - shared: 0, - notShared: 0, - }); + albumMock.getOwned.mockResolvedValue([]); + albumMock.getShared.mockResolvedValue([]); + albumMock.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); @@ -67,7 +55,6 @@ describe(AlbumService.name, () => { { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); expect(result).toHaveLength(2); @@ -85,7 +72,6 @@ describe(AlbumService.name, () => { endDate: new Date('1970-01-01'), }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); expect(result).toHaveLength(1); @@ -98,7 +84,6 @@ describe(AlbumService.name, () => { albumMock.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: true }); expect(result).toHaveLength(1); @@ -111,7 +96,6 @@ describe(AlbumService.name, () => { albumMock.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: false }); expect(result).toHaveLength(1); @@ -130,7 +114,6 @@ describe(AlbumService.name, () => { endDate: new Date('1970-01-01'), }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -139,48 +122,6 @@ describe(AlbumService.name, () => { expect(albumMock.getOwned).toHaveBeenCalledTimes(1); }); - it('updates the album thumbnail by listing all albums', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]); - albumMock.getMetadataForIds.mockResolvedValue([ - { - albumId: albumStub.oneAssetInvalidThumbnail.id, - assetCount: 1, - startDate: new Date('1970-01-01'), - endDate: new Date('1970-01-01'), - }, - ]); - albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); - albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail); - assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]); - - const result = await sut.getAll(authStub.admin, {}); - - expect(result).toHaveLength(1); - expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledTimes(1); - }); - - it('removes the thumbnail for an empty album', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]); - albumMock.getMetadataForIds.mockResolvedValue([ - { - albumId: albumStub.emptyWithInvalidThumbnail.id, - assetCount: 1, - startDate: new Date('1970-01-01'), - endDate: new Date('1970-01-01'), - }, - ]); - albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); - albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail); - assetMock.getFirstAssetForAlbumId.mockResolvedValue(null); - - const result = await sut.getAll(authStub.admin, {}); - - expect(result).toHaveLength(1); - expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledTimes(1); - }); - describe('create', () => { it('creates album', async () => { albumMock.create.mockResolvedValue(albumStub.empty); @@ -205,6 +146,10 @@ describe(AlbumService.name, () => { expect(userMock.get).toHaveBeenCalledWith('user-id', {}); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { + id: albumStub.empty.id, + userId: 'user-id', + }); }); it('should require valid userIds', async () => { @@ -361,6 +306,17 @@ describe(AlbumService.name, () => { expect(albumMock.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); + await expect( + sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { + albumUsers: [{ userId: userStub.user1.id }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(albumMock.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)); @@ -380,7 +336,7 @@ describe(AlbumService.name, () => { userId: authStub.user2.user.id, albumId: albumStub.sharedWithAdmin.id, }); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.sharedWithAdmin.id, userId: userStub.user2.id, }); @@ -470,6 +426,19 @@ describe(AlbumService.name, () => { }); }); + describe('updateUser', () => { + it('should update user role', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { + role: AlbumUserRole.EDITOR, + }); + expect(albumUserMock.update).toHaveBeenCalledWith( + { albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id }, + { role: AlbumUserRole.EDITOR }, + ); + }); + }); + describe('getAlbumInfo', () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); @@ -568,10 +537,6 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { - id: 'album-123', - updatedBy: authStub.admin.user.id, - }); }); it('should not set the thumbnail if the album has one already', async () => { @@ -612,9 +577,9 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: 'asset-1', }); expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', { + expect(eventMock.emit).toHaveBeenCalledWith('album.update', { id: 'album-123', - updatedBy: authStub.user1.user.id, + recipientIds: ['admin_id'], }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 9cd750e6b1..2cf83e9b99 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,10 +1,9 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AddUsersDto, - AlbumCountResponseDto, AlbumInfoDto, AlbumResponseDto, + AlbumStatisticsResponseDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, @@ -17,29 +16,14 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; -import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { Permission } from 'src/enum'; +import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; +import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() -export class AlbumService { - private access: AccessCore; - constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - - async getCount(auth: AuthDto): Promise { +export class AlbumService extends BaseService { + async getStatistics(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ this.albumRepository.getOwned(auth.user.id), this.albumRepository.getShared(auth.user.id), @@ -54,11 +38,7 @@ export class AlbumService { } async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise { - const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); - for (const albumId of invalidAlbumIds) { - const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); - await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); - } + await this.albumRepository.updateThumbnails(); let albums: AlbumEntity[]; if (assetId) { @@ -101,7 +81,7 @@ export class AlbumService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -125,7 +105,11 @@ export class AlbumService { } } - const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds)); + const allowedAssetIdsSet = await this.checkAccess({ + auth, + permission: Permission.ASSET_SHARE, + ids: dto.assetIds || [], + }); const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity); const album = await this.albumRepository.create({ @@ -137,11 +121,15 @@ export class AlbumService { albumThumbnailAssetId: assets[0]?.id || null, }); + for (const { userId } of albumUsers) { + await this.eventRepository.emit('album.invite', { id: album.id, userId }); + } + return mapAlbumWithAssets(album); } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_UPDATE, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -164,17 +152,17 @@ export class AlbumService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_ADD_ASSET, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); const results = await addAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.accessRepository, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids }, ); @@ -186,19 +174,25 @@ export class AlbumService { albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); - await this.eventRepository.emit('onAlbumUpdateEvent', { id, updatedBy: auth.user.id }); + const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter( + (userId) => userId !== auth.user.id, + ); + + if (allUsersExceptUs.length > 0) { + await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs }); + } } return results; } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_REMOVE_ASSET, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( auth, - { accessRepository: this.accessRepository, repository: this.albumRepository }, + { access: this.accessRepository, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, ); @@ -214,7 +208,7 @@ export class AlbumService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -233,8 +227,8 @@ export class AlbumService { throw new BadRequestException('User not found'); } - await this.albumUserRepository.create({ userId: userId, albumId: id, role }); - await this.eventRepository.emit('onAlbumInviteEvent', { id, userId }); + await this.albumUserRepository.create({ userId, albumId: id, role }); + await this.eventRepository.emit('album.invite', { id, userId }); } return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets); @@ -258,15 +252,14 @@ export class AlbumService { // non-admin can remove themselves if (auth.user.id !== userId) { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); } await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); - + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 2b5efc674f..3841ba1be9 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,31 +1,31 @@ import { BadRequestException } from '@nestjs/common'; +import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(APIKeyService.name, () => { let sut: APIKeyService; - let keyMock: Mocked; + let cryptoMock: Mocked; + let keyMock: Mocked; beforeEach(() => { - cryptoMock = newCryptoRepositoryMock(); - keyMock = newKeyRepositoryMock(); - sut = new APIKeyService(cryptoMock, keyMock); + ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); }); describe('create', () => { it('should create a new key', async () => { keyMock.create.mockResolvedValue(keyStub.admin); - await sut.create(authStub.admin, { name: 'Test Key' }); + 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, }); expect(cryptoMock.newPassword).toHaveBeenCalled(); @@ -35,16 +35,26 @@ describe(APIKeyService.name, () => { it('should not require a name', async () => { keyMock.create.mockResolvedValue(keyStub.admin); - await sut.create(authStub.admin, {}); + await sut.create(authStub.admin, { permissions: [Permission.ALL] }); expect(keyMock.create).toHaveBeenCalledWith({ key: 'cmFuZG9tLWJ5dGVz (hashed)', name: 'API Key', + permissions: [Permission.ALL], userId: authStub.admin.user.id, }); expect(cryptoMock.newPassword).toHaveBeenCalled(); expect(cryptoMock.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.admin, permissions: [] } }, + { permissions: [Permission.ASSET_READ] }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('update', () => { diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 24a57d3651..303ca05537 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,50 +1,51 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto } from 'src/dtos/api-key.dto'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { APIKeyEntity } from 'src/entities/api-key.entity'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { BaseService } from 'src/services/base.service'; +import { isGranted } from 'src/utils/access'; @Injectable() -export class APIKeyService { - constructor( - @Inject(ICryptoRepository) private crypto: ICryptoRepository, - @Inject(IKeyRepository) private repository: IKeyRepository, - ) {} - +export class APIKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { - const secret = this.crypto.newPassword(32); - const entity = await this.repository.create({ - key: this.crypto.hashSha256(secret), + const secret = this.cryptoRepository.newPassword(32); + + if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { + throw new BadRequestException('Cannot grant permissions you do not have'); + } + + const entity = await this.keyRepository.create({ + key: this.cryptoRepository.hashSha256(secret), name: dto.name || 'API Key', userId: auth.user.id, + permissions: dto.permissions, }); return { secret, apiKey: this.map(entity) }; } - async update(auth: AuthDto, id: string, dto: APIKeyCreateDto): Promise { - const exists = await this.repository.getById(auth.user.id, id); + async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { + const exists = await this.keyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - const key = await this.repository.update(auth.user.id, id, { name: dto.name }); + const key = await this.keyRepository.update(auth.user.id, id, { name: dto.name }); return this.map(key); } async delete(auth: AuthDto, id: string): Promise { - const exists = await this.repository.getById(auth.user.id, id); + const exists = await this.keyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - await this.repository.delete(auth.user.id, id); + await this.keyRepository.delete(auth.user.id, id); } async getById(auth: AuthDto, id: string): Promise { - const key = await this.repository.getById(auth.user.id, id); + const key = await this.keyRepository.getById(auth.user.id, id); if (!key) { throw new BadRequestException('API Key not found'); } @@ -52,7 +53,7 @@ export class APIKeyService { } async getAll(auth: AuthDto): Promise { - const keys = await this.repository.getByUserId(auth.user.id); + const keys = await this.keyRepository.getByUserId(auth.user.id); return keys.map((key) => this.map(key)); } @@ -62,6 +63,7 @@ export class APIKeyService { name: entity.name, createdAt: entity.createdAt, updatedAt: entity.updatedAt, + permissions: entity.permissions, }; } } diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 039dcb9aae..66f8061d3c 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -2,7 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; -import { ONE_HOUR, resourcePaths } from 'src/constants'; +import { ONE_HOUR } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { JobService } from 'src/services/job.service'; @@ -37,6 +38,7 @@ export class ApiService { private jobService: JobService, private sharedLinkService: SharedLinkService, private versionService: VersionService, + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ApiService.name); @@ -53,6 +55,8 @@ export class ApiService { } ssr(excludePaths: string[]) { + const { resourcePaths } = this.configRepository.getEnv(); + let index = ''; try { index = readFileSync(resourcePaths.web.indexHtml).toString(); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 3990b4c3de..c269739935 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -1,26 +1,27 @@ -import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; -import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; +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 { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetMediaService } from 'src/services/asset-media.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { userStub } from 'test/fixtures/user.stub'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { QueryFailedError } from 'typeorm'; import { Mocked } from 'vitest'; @@ -149,15 +150,14 @@ const assetEntity = Object.freeze({ deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/asset_1.jpeg', - previewPath: '', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, isArchived: false, - thumbnailPath: '', encodedVideoPath: '', duration: '0:00:00.000000', + files: [] as AssetFileEntity[], exifInfo: { latitude: 49.533_547, longitude: 10.703_075, @@ -188,27 +188,22 @@ const copiedAsset = Object.freeze({ describe(AssetMediaService.name, () => { let sut: AssetMediaService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; let jobMock: Mocked; - let loggerMock: Mocked; let storageMock: Mocked; let userMock: Mocked; - let eventMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - eventMock = newEventRepositoryMock(); - - sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); + ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); }); describe('getUploadAssetIdByChecksum', () => { + it('should return if checksum is undefined', async () => { + await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined); + }); + 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); @@ -310,6 +305,35 @@ describe(AssetMediaService.name, () => { }); describe('uploadAsset', () => { + it('should throw an error if the quota is exceeded', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 42, + }; + + assetMock.create.mockResolvedValue(assetEntity); + + await expect( + sut.uploadAsset( + { ...authStub.admin, user: { ...authStub.admin.user, quotaSizeInBytes: 42, quotaUsageInBytes: 1 } }, + createDto, + file, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.create).not.toHaveBeenCalled(); + expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(storageMock.utimes).not.toHaveBeenCalledWith( + file.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + }); + it('should handle a file upload', async () => { const file = { uuid: 'random-uuid', @@ -363,6 +387,31 @@ describe(AssetMediaService.name, () => { expect(userMock.updateUsage).not.toHaveBeenCalled(); }); + it('should throw an error if the duplicate could not be found by checksum', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 0, + }; + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.create.mockRejectedValue(error); + + await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: ['fake_path/asset_1.jpeg', undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + it('should handle a live photo', async () => { assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); @@ -400,6 +449,23 @@ describe(AssetMediaService.name, () => { expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(assetMock.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); + + await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ + status: AssetMediaStatus.CREATED, + id: assetStub.image.id, + }); + + expect(storageMock.utimes).toHaveBeenCalledWith( + fileStub.photoSidecar.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + expect(assetMock.update).not.toHaveBeenCalled(); + }); }); describe('downloadOriginal', () => { @@ -417,7 +483,7 @@ describe(AssetMediaService.name, () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); - expect(assetMock.getById).toHaveBeenCalledWith('asset-1'); + expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true }); }); it('should download a file', async () => { @@ -434,6 +500,170 @@ describe(AssetMediaService.name, () => { }); }); + describe('viewThumbnail', () => { + 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'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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: [] }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview', + type: AssetFileType.THUMBNAIL, + updatedAt: new Date(), + }, + ], + }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + 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({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview.jpg', + type: AssetFileType.PREVIEW, + updatedAt: new Date(), + }, + ], + }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: '/path/to/preview.jpg', + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get preview file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[0].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get thumbnail file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[1].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('playbackVideo', () => { + 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'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + 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); + + 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); + + await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.hasEncodedVideo.encodedVideoPath!, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'video/mp4', + }), + ); + }); + + it('should fall back to the original path', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + assetMock.getById.mockResolvedValue(assetStub.video); + + await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.video.originalPath, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('checkExistingAssets', () => { + it('should get existing asset ids', async () => { + assetMock.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']); + }); + }); + describe('replaceAsset', () => { it('should error when update photo does not exist', async () => { assetMock.getById.mockResolvedValueOnce(null); @@ -477,7 +707,10 @@ describe(AssetMediaService.name, () => { }), ); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]); + expect(assetMock.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( updatedFile.originalPath, @@ -505,7 +738,10 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); + expect(assetMock.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( updatedFile.originalPath, @@ -531,7 +767,10 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); + expect(assetMock.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( updatedFile.originalPath, @@ -560,7 +799,7 @@ describe(AssetMediaService.name, () => { }); expect(assetMock.create).not.toHaveBeenCalled(); - expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [updatedFile.originalPath, undefined] }, @@ -588,8 +827,52 @@ describe(AssetMediaService.name, () => { }), ).resolves.toEqual({ results: [ - { id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, - { id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + assetId: 'asset-2', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + ], + }); + + expect(assetMock.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]); + + await expect( + sut.bulkUploadCheck(authStub.admin, { + assets: [ + { id: '1', checksum: file1.toString('hex') }, + { id: '2', checksum: file2.toString('base64') }, + ], + }), + ).resolves.toEqual({ + results: [ + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + action: AssetUploadAction.ACCEPT, + }, ], }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8895e1c369..70f4905de3 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,14 +1,7 @@ -import { - BadRequestException, - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { AccessCore, Permission } from 'src/cores/access.core'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -27,15 +20,13 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +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 { BaseService } from 'src/services/base.service'; +import { requireUploadAccess } from 'src/utils/access'; +import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; @@ -54,22 +45,7 @@ export interface UploadFile { } @Injectable() -export class AssetMediaService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(AssetMediaService.name); - this.access = AccessCore.create(accessRepository); - } - +export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { if (!checksum) { return; @@ -84,7 +60,7 @@ export class AssetMediaService { } canUploadFile({ auth, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const filename = file.originalName; @@ -116,7 +92,7 @@ export class AssetMediaService { } getUploadFilename({ auth, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(auth); + requireUploadAccess(auth); const originalExtension = extname(file.originalName); @@ -130,7 +106,7 @@ export class AssetMediaService { } getUploadFolder({ auth, fieldName, file }: UploadRequest): string { - auth = this.access.requireUploadAccess(auth); + auth = requireUploadAccess(auth); let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); if (fieldName === UploadFieldName.PROFILE_DATA) { @@ -149,30 +125,20 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission( + await this.requireAccess({ auth, - Permission.ASSET_UPLOAD, + permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it - auth.user.id, - ); + ids: [auth.user.id], + }); this.requireQuota(auth, file.size); if (dto.livePhotoVideoId) { - const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); - if (!motionAsset) { - throw new BadRequestException('Live photo video not found'); - } - if (motionAsset.type !== AssetType.VIDEO) { - throw new BadRequestException('Live photo vide must be a video'); - } - if (motionAsset.ownerId !== auth.user.id) { - throw new BadRequestException('Live photo video does not belong to the user'); - } - if (motionAsset.isVisible) { - await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id); - } + await onBeforeLink( + { asset: this.assetRepository, event: this.eventRepository }, + { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, + ); } const asset = await this.create(auth.user.id, dto, file, sidecarFile); @@ -193,7 +159,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -204,9 +170,8 @@ export class AssetMediaService { // but the local variable holds the original file data paths. const copiedPhoto = await this.createCopy(asset); // and immediate trash it - await this.assetRepository.softDeleteAll([copiedPhoto.id]); - - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); + await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED }); + await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id }); await this.userRepository.updateUsage(auth.user.id, file.size); @@ -217,12 +182,9 @@ export class AssetMediaService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } return new ImmichFileResponse({ path: asset.originalPath, @@ -232,14 +194,15 @@ export class AssetMediaService { } async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - let filepath = asset.previewPath; - if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) { - filepath = asset.thumbnailPath; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + let filepath = previewFile?.path; + if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { + filepath = thumbnailFile.path; } if (!filepath) { @@ -254,12 +217,9 @@ export class AssetMediaService { } async playbackVideo(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_VIEW, id); + await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } if (asset.type !== AssetType.VIDEO) { throw new BadRequestException('Asset is not a video'); @@ -289,10 +249,10 @@ export class AssetMediaService { async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); - const checksumMap: Record = {}; + const checksumMap: Record = {}; - for (const { id, checksum } of results) { - checksumMap[checksum.toString('hex')] = id; + for (const { id, deletedAt, checksum } of results) { + checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt }; } return { @@ -301,14 +261,13 @@ export class AssetMediaService { if (duplicate) { return { id, - assetId: duplicate, action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE, + assetId: duplicate.id, + isTrashed: duplicate.isTrashed, }; } - // TODO mime-check - return { id, action: AssetUploadAction.ACCEPT, @@ -439,7 +398,6 @@ export class AssetMediaService { livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, - isOffline: dto.isOffline ?? false, }); if (sidecarFile) { @@ -459,7 +417,7 @@ export class AssetMediaService { } private async findOrFail(id: string): Promise { - const asset = await this.assetRepository.getById(id); + const asset = await this.assetRepository.getById(id, { files: true }); if (!asset) { throw new NotFoundException('Asset not found'); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5e5e555383..9063df9dc2 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,30 +1,24 @@ import { BadRequestException } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetStatus, AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { AssetService } from 'src/services/asset.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +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, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; const stats: AssetStats = { @@ -42,15 +36,15 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let jobMock: Mocked; - let userMock: Mocked; let eventMock: Mocked; + let jobMock: Mocked; + let partnerMock: Mocked; let stackMock: Mocked; let systemMock: Mocked; - let partnerMock: Mocked; - let loggerMock: Mocked; + let userMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -63,27 +57,8 @@ describe(AssetService.name, () => { }; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - userMock = newUserRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - stackMock = newStackRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new AssetService( - accessMock, - assetMock, - jobMock, - systemMock, - userMock, - eventMock, - partnerMock, - stackMock, - loggerMock, - ); + ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = + newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -154,6 +129,28 @@ describe(AssetService.name, () => { }); }); + describe('getRandom', () => { + it('should get own random assets', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.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 }]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.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]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.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])); @@ -175,6 +172,23 @@ describe(AssetService.name, () => { ); }); + 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); + + const result = await sut.get( + { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, + assetStub.image.id, + ); + + expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); + expect(result).not.toHaveProperty('exifInfo'); + expect(accessMock.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); @@ -205,6 +219,11 @@ describe(AssetService.name, () => { expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(assetMock.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])); + await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('update', () => { @@ -228,6 +247,139 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); expect(assetMock.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.mockResolvedValue(assetStub.image); + await sut.update(authStub.admin, 'asset-1', { rating: 3 }); + expect(assetMock.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])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.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', { + 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); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.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', { + 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); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.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', { + 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({ + ...assetStub.livePhotoMotionAsset, + ownerId: authStub.admin.user.id, + isVisible: true, + }); + assetMock.getById.mockResolvedValueOnce(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', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + expect(assetMock.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'])); + 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.getById.mockResolvedValueOnce(assetStub.image); + + await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + + expect(assetMock.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: null, + }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(eventMock.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])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(eventMock.emit).not.toHaveBeenCalledWith(); + }); }); describe('updateAll', () => { @@ -245,134 +397,6 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); - - /// Stack related - - it('should require asset update access for parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect( - sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should update parent asset updatedAt when children are added', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent'])); - mockGetById([{ ...assetStub.image, id: 'parent' }]); - await sut.updateAll(authStub.user1, { - ids: [], - stackParentId: 'parent', - }), - expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) }); - }); - - it('should update parent asset when children are removed', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1'])); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - removeParent: true, - }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null }); - expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { - updatedAt: expect.any(Date), - }); - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - }); - - it('update parentId for new children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - const stack = stackStub('stack-1', [ - { id: 'parent' } as AssetEntity, - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - ]); - assetMock.getById.mockResolvedValue({ - id: 'child-1', - stack, - } as AssetEntity); - - await sut.updateAll(authStub.user1, { - stackParentId: 'parent', - ids: ['child-1', 'child-2'], - }); - - expect(stackMock.update).toHaveBeenCalledWith({ - ...stackStub('stack-1', [ - { id: 'child-1' } as AssetEntity, - { id: 'child-2' } as AssetEntity, - { id: 'parent' } as AssetEntity, - ]), - primaryAsset: undefined, - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) }); - }); - - it('remove stack for removed children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2'])); - await sut.updateAll(authStub.user1, { - removeParent: true, - ids: ['child-1', 'child-2'], - }); - - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null }); - }); - - it('merge stacks if new child has children', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, id: 'parent' }); - assetMock.getByIds.mockResolvedValue([ - { - id: 'child-1', - stackId: 'stack-1', - stack: stackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]), - } as AssetEntity, - ]); - stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity])); - - await sut.updateAll(authStub.user1, { - ids: ['child-1'], - stackParentId: 'parent', - }); - - expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); - expect(stackMock.create).toHaveBeenCalledWith({ - assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }], - ownerId: 'user-id', - primaryAssetId: 'parent', - }); - expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], { - updatedAt: expect.any(Date), - }); - }); - - it('should send ws asset update event', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1'])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent'])); - assetMock.getById.mockResolvedValue(assetStub.image); - - await sut.updateAll(authStub.user1, { - ids: ['asset-1'], - stackParentId: 'parent', - }); - - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [ - 'asset-1', - 'parent', - ]); - }); }); describe('deleteAll', () => { @@ -389,10 +413,10 @@ describe(AssetService.name, () => { await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } }, - { name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } }, - ]); + expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', { + assetIds: ['asset1', 'asset2'], + userId: 'user-id', + }); }); it('should soft delete a batch of assets', async () => { @@ -400,11 +424,50 @@ describe(AssetService.name, () => { await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { + deletedAt: expect.any(Date), + status: AssetStatus.TRASHED, + }); expect(jobMock.queue.mock.calls).toEqual([]); }); }); + describe('handleAssetDeletionCheck', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + 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 } }); + + 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 } }, + ]); + }); + + 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 } }); + + 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 } }, + ]); + }); + }); + describe('handleAssetDeletion', () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; @@ -419,8 +482,8 @@ describe(AssetService.name, () => { name: JobName.DELETE_FILES, data: { files: [ - assetWithFace.thumbnailPath, - assetWithFace.previewPath, + '/uploads/user-id/webp/path.ext', + '/uploads/user-id/thumbs/path.jpg', assetWithFace.encodedVideoPath, assetWithFace.sidecarPath, assetWithFace.originalPath, @@ -444,6 +507,17 @@ describe(AssetService.name, () => { }); }); + 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({ + ...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'); + }); + it('should delete a live photo', async () => { assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getLivePhotoCount.mockResolvedValue(0); @@ -500,75 +574,51 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); + + it('should fail if asset could not be found', async () => { + await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( + JobStatus.FAILED, + ); + }); }); describe('run', () => { + it('should run the refresh faces job', async () => { + accessMock.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' } }]); + }); + it('should run the refresh metadata job', async () => { accessMock.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' } }]); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); + expect(jobMock.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'])); - await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }), - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: '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' } }]); }); it('should run the transcode video', async () => { accessMock.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' } }]); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); }); }); - describe('updateStackParent', () => { - it('should require asset update access for new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); + describe('getUserAssetsByDeviceId', () => { + it('get assets by device id', async () => { + const assets = [assetStub.image, assetStub.image1]; + + assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); + + const deviceId = 'device-id'; + const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); + + expect(result.length).toEqual(2); + expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); - - it('should require asset read access for old parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new'])); - await expect( - sut.updateStackParent(authStub.user1, { - oldParentId: 'old', - newParentId: 'new', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('make old parent the child of new parent', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id])); - accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new'])); - assetMock.getById.mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' }); - - await sut.updateStackParent(authStub.user1, { - oldParentId: assetStub.image.id, - newParentId: 'new', - }); - - expect(stackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' }); - expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], { - updatedAt: expect.any(Date), - }); - }); - }); - - it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; - - assetMock.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); - - const deviceId = 'device-id'; - const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId); - - expect(result.length).toEqual(2); - expect(result).toEqual(assets.map((asset) => asset.deviceAssetId)); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5c6cce27d8..2f31806e81 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,8 +1,6 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, @@ -20,48 +18,21 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; -import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { AssetStatus, Permission } from 'src/enum'; import { IAssetDeleteJob, - IJobRepository, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobItem, JobName, JobStatus, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { getMyPartnerIds } from 'src/utils/asset.util'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; -export class AssetService { - private access: AccessCore; - private configCore: SystemConfigCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IStackRepository) private stackRepository: IStackRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(AssetService.name); - this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class AssetService extends BaseService { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const partnerIds = await getMyPartnerIds({ userId: auth.user.id, @@ -71,9 +42,10 @@ export class AssetService { const userIds = [auth.user.id, ...partnerIds]; const assets = await this.assetRepository.getByDayOfYear(userIds, dto); + const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile); const groups: Record = {}; const currentYear = new Date().getFullYear(); - for (const asset of assets) { + for (const asset of assetsWithThumbnails) { const yearsAgo = currentYear - asset.localDateTime.getFullYear(); if (!groups[yearsAgo]) { groups[yearsAgo] = []; @@ -99,7 +71,12 @@ export class AssetService { } async getRandom(auth: AuthDto, count: number): Promise { - const assets = await this.assetRepository.getRandom(auth.user.id, count); + const partnerIds = await getMyPartnerIds({ + userId: auth.user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + const assets = await this.assetRepository.getRandom([auth.user.id, ...partnerIds], count); return assets.map((a) => mapAsset(a, { auth })); } @@ -108,15 +85,15 @@ export class AssetService { } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, id); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] }); const asset = await this.assetRepository.getById( id, { exifInfo: true, - tags: true, sharedLinks: true, smartInfo: true, + tags: true, owner: true, faces: { person: true, @@ -126,6 +103,7 @@ export class AssetService { exifInfo: true, }, }, + files: true, }, { faces: { @@ -156,12 +134,29 @@ export class AssetService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); - const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); + const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const repos = { asset: this.assetRepository, event: this.eventRepository }; + + let previousMotion: AssetEntity | null = null; + if (rest.livePhotoVideoId) { + await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); + } else if (rest.livePhotoVideoId === null) { + const asset = await this.findOrFail(id); + if (asset.livePhotoVideoId) { + previousMotion = await onBeforeUnlink(repos, { livePhotoVideoId: asset.livePhotoVideoId }); + } + } + + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); + + if (previousMotion) { + await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); + } + const asset = await this.assetRepository.getById(id, { exifInfo: true, owner: true, @@ -170,80 +165,29 @@ export class AssetService { faces: { person: true, }, + files: true, }); + if (!asset) { throw new BadRequestException('Asset not found'); } + return mapAsset(asset, { auth }); } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto; - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids); - - // TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc. - const stackIdsToCheckForDelete: string[] = []; - if (removeParent) { - (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids, { stack: true }); - stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); - // This updates the updatedAt column of the parents to indicate that one of its children is removed - // All the unique parent's -> parent is set to null - await this.assetRepository.updateAll( - assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!), - { updatedAt: new Date() }, - ); - } else if (options.stackParentId) { - //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); - if (!primaryAsset) { - throw new BadRequestException('Asset not found for given stackParentId'); - } - let stack = primaryAsset.stack; - - ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); - stackIdsToCheckForDelete.push( - ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), - ); - const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0); - ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id))); - - if (stack) { - await this.stackRepository.update({ - id: stack.id, - primaryAssetId: primaryAsset.id, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } else { - stack = await this.stackRepository.create({ - primaryAssetId: primaryAsset.id, - ownerId: primaryAsset.ownerId, - assets: ids.map((id) => ({ id }) as AssetEntity), - }); - } - - // Merge stacks - options.stackParentId = undefined; - (options as Partial).updatedAt = new Date(); - } + const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } await this.assetRepository.updateAll(ids, options); - const stackIdsToDelete = await Promise.all(stackIdsToCheckForDelete.map((id) => this.stackRepository.getById(id))); - const stacksToDelete = stackIdsToDelete - .flatMap((stack) => (stack ? [stack] : [])) - .filter((stack) => stack.assets.length < 2); - await Promise.all(stacksToDelete.map((as) => this.stackRepository.delete(as.id))); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); } async handleAssetDeletionCheck(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedBefore = DateTime.now() .minus(Duration.fromObject({ days: trashedDays })) @@ -277,6 +221,7 @@ export class AssetService { library: true, stack: { assets: true }, exifInfo: true, + files: true, }); if (!asset) { @@ -301,7 +246,8 @@ export class AssetService { if (!asset.libraryId) { await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); } - this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); + + await this.eventRepository.emit('asset.delete', { assetId: id, userId: asset.ownerId }); // delete the motion if it is not used by another asset if (asset.livePhotoVideoId) { @@ -314,7 +260,8 @@ export class AssetService { } } - const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; + const { thumbnailFile, previewFile } = getAssetFiles(asset.files); + const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath]; if (deleteOnDisk) { files.push(asset.sidecarPath, asset.originalPath); } @@ -327,70 +274,33 @@ export class AssetService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids); - - if (force) { - await this.jobRepository.queueAll( - ids.map((id) => ({ - name: JobName.ASSET_DELETION, - data: { id, deleteOnDisk: true }, - })), - ); - } else { - await this.assetRepository.softDeleteAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids); - } - } - - async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { - const { oldParentId, newParentId } = dto; - await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId); - - const childIds: string[] = []; - const oldParent = await this.assetRepository.getById(oldParentId, { - faces: { - person: true, - }, - library: true, - stack: { - assets: true, - }, + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); + await this.assetRepository.updateAll(ids, { + deletedAt: new Date(), + status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, }); - if (!oldParent?.stackId) { - throw new Error('Asset not found or not in a stack'); - } - if (oldParent != null) { - // Get all children of old parent - childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? [])); - } - await this.stackRepository.update({ - id: oldParent.stackId, - primaryAssetId: newParentId, - }); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [ - ...childIds, - newParentId, - oldParentId, - ]); - await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() }); + await this.eventRepository.emit(force ? 'assets.delete' : 'assets.trash', { assetIds: ids, userId: auth.user.id }); } async run(auth: AuthDto, dto: AssetJobsDto) { - await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const jobs: JobItem[] = []; for (const id of dto.assetIds) { switch (dto.name) { + case AssetJobName.REFRESH_FACES: { + jobs.push({ name: JobName.FACE_DETECTION, data: { id } }); + break; + } + case AssetJobName.REFRESH_METADATA: { jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } }); break; } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } @@ -404,9 +314,17 @@ export class AssetService { await this.jobRepository.queueAll(jobs); } + private async findOrFail(id: string) { + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + return asset; + } + private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index 8557677f92..c7a51565af 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,46 +1,28 @@ -import { DatabaseAction, EntityType } from 'src/entities/audit.entity'; +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 { IAuditRepository } from 'src/interfaces/audit.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuditService } from 'src/services/audit.service'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; -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 { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(AuditService.name, () => { let sut: AuditService; - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; let auditMock: Mocked; + let assetMock: Mocked; let cryptoMock: Mocked; let personMock: Mocked; - let storageMock: Mocked; let userMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - auditMock = newAuditRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock, loggerMock); + ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService)); }); it('should work', () => { @@ -87,4 +69,148 @@ describe(AuditService.name, () => { }); }); }); + + describe('getChecksums', () => { + 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(); + }); + + it('should get checksum for valid file', async () => { + await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([ + { filename: './upload/my-file.jpg', checksum: expect.any(String) }, + ]); + + expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); + }); + }); + + describe('fixItems', () => { + it('should fail if the file is not in the immich path', async () => { + await expect( + sut.fixItems([ + { entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto, + ]), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update encoded video path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.ENCODED_VIDEO, + pathValue: './upload/my-video.mp4', + } 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(); + }); + + it('should update preview path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.PREVIEW, + pathValue: './upload/my-preview.png', + } as FileReportItemDto, + ]); + + expect(assetMock.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(); + }); + + it('should update thumbnail path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.THUMBNAIL, + pathValue: './upload/my-thumbnail.webp', + } as FileReportItemDto, + ]); + + expect(assetMock.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(); + }); + + it('should update original path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.ORIGINAL, + pathValue: './upload/my-original.png', + } 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(); + }); + + it('should update sidecar path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.SIDECAR, + pathValue: './upload/my-sidecar.xmp', + } 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(); + }); + + it('should update face path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: PersonPathType.FACE, + pathValue: './upload/my-face.jpg', + } 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(); + }); + + it('should update profile path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: UserPathType.PROFILE, + pathValue: './upload/my-profile-pic.jpg', + } 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(); + }); + }); }); diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index bfff09c0bc..d891c88b39 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,9 +1,8 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AuditDeletesDto, AuditDeletesResponseDto, @@ -13,47 +12,32 @@ import { PathEntityType, } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DatabaseAction } from 'src/entities/audit.entity'; -import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + AssetFileType, + AssetPathType, + DatabaseAction, + Permission, + PersonPathType, + StorageFolder, + UserPathType, +} from 'src/enum'; import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class AuditService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IAuditRepository) private repository: IAuditRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.access = AccessCore.create(accessRepository); - this.logger.setContext(AuditService.name); - } - +export class AuditService extends BaseService { async handleCleanup(): Promise { - await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); + await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); return JobStatus.SUCCESS; } async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); - const audits = await this.repository.getAfter(dto.after, { + const audits = await this.auditRepository.getAfter(dto.after, { userIds: [userId], entityType: dto.entityType, action: DatabaseAction.DELETE, @@ -97,12 +81,12 @@ export class AuditService { } case AssetPathType.PREVIEW: { - await this.assetRepository.update({ id, previewPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue }); break; } case AssetPathType.THUMBNAIL: { - await this.assetRepository.update({ id, thumbnailPath: pathValue }); + await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue }); break; } @@ -155,7 +139,7 @@ export class AuditService { } } - const track = (filename: string | null) => { + const track = (filename: string | null | undefined) => { if (!filename) { return; } @@ -175,8 +159,9 @@ export class AuditService { const orphans: FileReportItemDto[] = []; for await (const assets of pagination) { assetCount += assets.length; - for (const { id, originalPath, previewPath, encodedVideoPath, thumbnailPath, isExternal, checksum } of assets) { - for (const file of [originalPath, previewPath, encodedVideoPath, thumbnailPath]) { + for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) { + const { previewFile, thumbnailFile } = getAssetFiles(files); + for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) { track(file); } @@ -192,11 +177,11 @@ export class AuditService { ) { orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath }); } - if (previewPath && !hasFile(thumbFiles, previewPath)) { - orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewPath }); + if (previewFile && !hasFile(thumbFiles, previewFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path }); } - if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { - orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailPath }); + if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) { + orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path }); } if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) { orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 7aa03e6bdd..3701d3de56 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,33 +1,35 @@ -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { IncomingHttpHeaders } from 'node:http'; -import { Issuer, generators } from 'openid-client'; -import { Socket } from 'socket.io'; -import { AuthType } from 'src/constants'; +import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; 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 { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.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 { keyStub } from 'test/fixtures/api-key.stub'; -import { authStub, loginResponseStub } from 'test/fixtures/auth.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 { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; -import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; -import { Mock, Mocked, vitest } from 'vitest'; +import { newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; + +const oauthResponse = { + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, +}; // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); @@ -48,7 +50,7 @@ const fixtures = { }; const oauthUserWithDefaultQuota = { - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 1_073_741_824, @@ -57,52 +59,36 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; - let cryptoMock: Mocked; - let userMock: Mocked; - let loggerMock: Mocked; - let systemMock: Mocked; - let sessionMock: Mocked; - let shareMock: Mocked; - let keyMock: Mocked; - let callbackMock: Mock; - let userinfoMock: Mock; + let cryptoMock: Mocked; + let eventMock: Mocked; + let keyMock: Mocked; + let oauthMock: Mocked; + let sessionMock: Mocked; + let sharedLinkMock: Mocked; + let systemMock: Mocked; + let userMock: Mocked; beforeEach(() => { - callbackMock = vitest.fn().mockReturnValue({ access_token: 'access-token' }); - userinfoMock = vitest.fn().mockResolvedValue({ sub, email }); + ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } = + newTestService(AuthService)); - vitest.spyOn(generators, 'state').mockReturnValue('state'); - vitest.spyOn(Issuer, 'discover').mockResolvedValue({ - id_token_signing_alg_values_supported: ['RS256'], - Client: vitest.fn().mockResolvedValue({ - issuer: { - metadata: { - end_session_endpoint: 'http://end-session-endpoint', - }, - }, - authorizationUrl: vitest.fn().mockReturnValue('http://authorization-url'), - callbackParams: vitest.fn().mockReturnValue({ state: 'state' }), - callback: callbackMock, - userinfo: userinfoMock, - }), - } as any); - - cryptoMock = newCryptoRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - sessionMock = newSessionRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - keyMock = newKeyRepositoryMock(); - - sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); + oauthMock.authorize.mockResolvedValue('access-token'); + oauthMock.getProfile.mockResolvedValue({ sub, email }); + oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); }); it('should be defined', () => { expect(sut).toBeDefined(); }); + describe('onBootstrap', () => { + it('should init the repo', () => { + sut.onBootstrap(); + expect(oauthMock.init).toHaveBeenCalled(); + }); + }); + describe('login', () => { it('should throw an error if password login is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.disabled); @@ -124,7 +110,15 @@ describe('AuthService', () => { it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); - await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); + await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, + }); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -210,6 +204,7 @@ describe('AuthService', () => { }); expect(sessionMock.delete).toHaveBeenCalledWith('token123'); + expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -252,15 +247,26 @@ describe('AuthService', () => { }); describe('validate - socket connections', () => { - it('should throw token is not provided', async () => { - await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException); + it('should throw when token is not provided', async () => { + await expect( + sut.authenticate({ + headers: {}, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); sessionMock.getByToken.mockResolvedValue(sessionStub.valid); - const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; - await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { authorization: 'Bearer auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.user1, session: sessionStub.valid, }); @@ -269,68 +275,130 @@ describe('AuthService', () => { describe('validate - shared key', () => { it('should not accept a non-existent key', async () => { - shareMock.getByKey.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + sharedLinkMock.getByKey.mockResolvedValue(null); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should not accept an expired key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('should not accept a key on a non-shared route', async () => { + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); }); it('should not accept a key without a user', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); userMock.get.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should accept a base64url key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); - const headers: IncomingHttpHeaders = { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); describe('validate - user token', () => { it('should throw if no token is found', async () => { sessionMock.getByToken.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-immich-user-token': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { sessionMock.getByToken.mockResolvedValue(sessionStub.valid); - const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toEqual({ user: userStub.user1, session: sessionStub.valid, }); }); + it('should throw if admin route and not an admin', async () => { + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: true, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('should update when access time exceeds an hour', async () => { sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); sessionMock.update.mockResolvedValue(sessionStub.valid); - const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toBeDefined(); + await expect( + sut.authenticate({ + headers: { cookie: 'immich_access_token=auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).resolves.toBeDefined(); expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); @@ -338,26 +406,63 @@ describe('AuthService', () => { describe('validate - api key', () => { it('should throw an error if no api key is found', async () => { keyMock.getKey.mockResolvedValue(null); - const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; - await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); + await expect( + sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); + it('should throw an error if api key has insufficient permissions', async () => { + keyMock.getKey.mockResolvedValue({ ...keyStub.admin, permissions: [] }); + await expect( + sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: Permission.ASSET_READ }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('should return an auth dto', async () => { keyMock.getKey.mockResolvedValue(keyStub.admin); - const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.admin }); + 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.admin }); expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); describe('getMobileRedirect', () => { it('should pass along the query params', () => { - expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); + expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual( + 'app.immich:///oauth-callback?code=123&state=456', + ); }); it('should work if called without query params', () => { - expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?'); + expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:///oauth-callback?'); + }); + }); + + describe('authorize', () => { + it('should fail if oauth is disabled', async () => { + systemMock.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); + await sut.authorize({ redirectUri: 'https://demo.immich.app' }); }); }); @@ -382,7 +487,7 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); @@ -411,32 +516,46 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create expect(userMock.create).toHaveBeenCalledTimes(1); }); - it('should use the mobile redirect override', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); + 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(null); + userMock.getAdmin.mockResolvedValue(userStub.user1); + userMock.create.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); + oauthMock.getProfile.mockResolvedValue({ sub, email: undefined }); - await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( + BadRequestException, + ); - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); + expect(userMock.getByEmail).not.toHaveBeenCalled(); + expect(userMock.create).not.toHaveBeenCalled(); }); - it('should use the mobile redirect override for ios urls with multiple slashes', async () => { - systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - userMock.getByOAuthId.mockResolvedValue(userStub.user1); - sessionMock.create.mockResolvedValue(sessionStub.valid); + for (const url of [ + 'app.immich:/', + 'app.immich://', + 'app.immich:///', + 'app.immich:/oauth-callback?code=abc123', + 'app.immich://oauth-callback?code=abc123', + '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); - await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); - - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); - }); + await sut.callback({ url }, loginDetails); + expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); + }); + } it('should use the default quota', async () => { systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); @@ -445,7 +564,7 @@ describe('AuthService', () => { userMock.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -456,10 +575,10 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -470,10 +589,10 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: -5 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -484,14 +603,14 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 0 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: null, @@ -504,14 +623,14 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 5 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ - email: email, + email, name: ' ', oauthId: sub, quotaSizeInBytes: 5_368_709_120, diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index c151c10a66..b0094ae9ed 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,24 +1,13 @@ -import { - BadRequestException, - Inject, - Injectable, - InternalServerErrorException, - UnauthorizedException, -} from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { isNumber, isString } from 'class-validator'; import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; -import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; -import { SystemConfig } from 'src/config'; -import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; +import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { OnEvent } from 'src/decorators'; import { AuthDto, ChangePasswordDto, - ImmichCookie, - ImmichHeader, LoginCredentialDto, LogoutResponseDto, OAuthAuthorizeResponseDto, @@ -29,13 +18,10 @@ import { } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; +import { OAuthProfile } from 'src/interfaces/oauth.interface'; +import { BaseService } from 'src/services/base.service'; +import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; export interface LoginDetails { @@ -45,37 +31,32 @@ export interface LoginDetails { deviceOS: string; } -type OAuthProfile = UserinfoResponse; - interface ClaimOptions { key: string; default: T; isValid: (value: unknown) => boolean; } +export type ValidateRequest = { + headers: IncomingHttpHeaders; + queryParams: Record; + metadata: { + sharedLinkRoute: boolean; + adminRoute: boolean; + permission?: Permission; + uri: string; + }; +}; + @Injectable() -export class AuthService { - private configCore: SystemConfigCore; - private userCore: UserCore; - - constructor( - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ISessionRepository) private sessionRepository: ISessionRepository, - @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, - @Inject(IKeyRepository) private keyRepository: IKeyRepository, - ) { - this.logger.setContext(AuthService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - this.userCore = UserCore.create(cryptoRepository, userRepository); - - custom.setHttpOptionsDefaults({ timeout: 30_000 }); +export class AuthService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap() { + this.oauthRepository.init(); } async login(dto: LoginCredentialDto, details: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } @@ -99,6 +80,7 @@ export class AuthService { async logout(auth: AuthDto, authType: AuthType): Promise { if (auth.session) { await this.sessionRepository.delete(auth.session.id); + await this.eventRepository.emit('session.delete', { sessionId: auth.session.id }); } return { @@ -132,7 +114,7 @@ export class AuthService { throw new BadRequestException('The server already has an admin'); } - const admin = await this.userCore.createUser({ + const admin = await this.createUser({ isAdmin: true, email: dto.email, name: dto.name, @@ -143,14 +125,35 @@ export class AuthService { return mapUserAdmin(admin); } - async validate(headers: IncomingHttpHeaders, params: Record): Promise { - const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || params.key) as string; + async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise { + const authDto = await this.validate({ headers, queryParams }); + const { adminRoute, sharedLinkRoute, permission, uri } = metadata; + + if (!authDto.user.isAdmin && adminRoute) { + this.logger.warn(`Denied access to admin only route: ${uri}`); + throw new ForbiddenException('Forbidden'); + } + + if (authDto.sharedLink && !sharedLinkRoute) { + this.logger.warn(`Denied access to non-shared route: ${uri}`); + throw new ForbiddenException('Forbidden'); + } + + if (authDto.apiKey && permission && !isGranted({ requested: [permission], current: authDto.apiKey.permissions })) { + throw new ForbiddenException(`Missing required permission: ${permission}`); + } + + return authDto; + } + + private async validate({ headers, queryParams }: Omit): Promise { + const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || queryParams[ImmichQuery.SHARED_LINK_KEY]) as string; const session = (headers[ImmichHeader.USER_TOKEN] || headers[ImmichHeader.SESSION_TOKEN] || - params.sessionKey || + queryParams[ImmichQuery.SESSION_KEY] || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; - const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string; + const apiKey = (headers[ImmichHeader.API_KEY] || queryParams[ImmichQuery.API_KEY]) as string; if (shareKey) { return this.validateSharedLink(shareKey); @@ -172,25 +175,20 @@ export class AuthService { } async authorize(dto: OAuthConfigDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); - if (!config.oauth.enabled) { + const { oauth } = await this.getConfig({ withCache: false }); + + if (!oauth.enabled) { throw new BadRequestException('OAuth is not enabled'); } - const client = await this.getOAuthClient(config); - const url = client.authorizationUrl({ - redirect_uri: this.normalize(config, dto.redirectUri), - scope: config.oauth.scope, - state: generators.state(), - }); - + const url = await this.oauthRepository.authorize(oauth, this.resolveRedirectUri(oauth, dto.redirectUri)); return { url }; } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); - const profile = await this.getOAuthProfile(config, dto.url); - const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth; + const { oauth } = await this.getConfig({ withCache: false }); + const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url)); + const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user = await this.userRepository.getByOAuthId(profile.sub); @@ -232,7 +230,7 @@ export class AuthService { }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; - user = await this.userCore.createUser({ + user = await this.createUser({ name: userName, email: profile.email, oauthId: profile.sub, @@ -245,8 +243,12 @@ export class AuthService { } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); - const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); + const { oauth } = await this.getConfig({ withCache: false }); + const { sub: oauthId } = await this.oauthRepository.getProfile( + oauth, + dto.url, + this.resolveRedirectUri(oauth, dto.url), + ); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.user.id) { this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`); @@ -267,65 +269,12 @@ export class AuthService { return LOGIN_URL; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { return LOGIN_URL; } - const client = await this.getOAuthClient(config); - return client.issuer.metadata.end_session_endpoint || LOGIN_URL; - } - - private async getOAuthProfile(config: SystemConfig, url: string): Promise { - const redirectUri = this.normalize(config, url.split('?')[0]); - const client = await this.getOAuthClient(config); - const params = client.callbackParams(url); - try { - const tokens = await client.callback(redirectUri, params, { state: params.state }); - return client.userinfo(tokens.access_token || ''); - } catch (error: Error | any) { - if (error.message.includes('unexpected JWT alg received')) { - this.logger.warn( - [ - 'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.', - 'Or, that you have specified a signing key in your OAuth provider.', - ].join(' '), - ); - } - - throw error; - } - } - - private async getOAuthClient(config: SystemConfig) { - const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm, profileSigningAlgorithm } = config.oauth; - - if (!enabled) { - throw new BadRequestException('OAuth2 is not enabled'); - } - - try { - const issuer = await Issuer.discover(issuerUrl); - return new issuer.Client({ - client_id: clientId, - client_secret: clientSecret, - response_types: ['code'], - userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, - id_token_signed_response_alg: signingAlgorithm, - }); - } catch (error: any | AggregateError) { - this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); - throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); - } - } - - private normalize(config: SystemConfig, redirectUri: string) { - const isMobile = redirectUri.startsWith(MOBILE_REDIRECT); - const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth; - if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { - return mobileRedirectUri; - } - return redirectUri; + return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL; } private getBearerToken(headers: IncomingHttpHeaders): string | null { @@ -385,7 +334,7 @@ export class AuthService { await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } - return { user: session.user, session: session }; + return { user: session.user, session }; } throw new UnauthorizedException('Invalid user token'); @@ -409,4 +358,16 @@ export class AuthService { const value = profile[options.key as keyof OAuthProfile]; return options.isValid(value) ? (value as T) : options.default; } + + private resolveRedirectUri( + { mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean }, + url: string, + ) { + const redirectUri = url.split('?')[0]; + const isMobile = redirectUri.startsWith('app.immich:/'); + if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { + return mobileRedirectUri; + } + return redirectUri; + } } diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts new file mode 100644 index 0000000000..8f006d0d6b --- /dev/null +++ b/server/src/services/backup.service.spec.ts @@ -0,0 +1,217 @@ +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 { IJobRepository, 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 { BackupService } from 'src/services/backup.service'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { mockSpawn, newTestService } from 'test/utils'; +import { describe, Mocked } from 'vitest'; + +describe(BackupService.name, () => { + let sut: BackupService; + + let databaseMock: Mocked; + let jobMock: Mocked; + let processMock: Mocked; + let storageMock: Mocked; + let systemMock: Mocked; + + beforeEach(() => { + ({ sut, databaseMock, jobMock, processMock, storageMock, systemMock } = newTestService(BackupService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('onBootstrapEvent', () => { + it('should init cron job and handle config changes', async () => { + databaseMock.tryLock.mockResolvedValue(true); + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + + await sut.onBootstrap(ImmichWorker.API); + + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should not initialize backup database cron job when lock is taken', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + databaseMock.tryLock.mockResolvedValue(false); + + await sut.onBootstrap(ImmichWorker.API); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + + it('should not initialise backup database job when running on microservices', async () => { + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + }); + + describe('onConfigUpdateEvent', () => { + beforeEach(async () => { + systemMock.get.mockResolvedValue(defaults); + databaseMock.tryLock.mockResolvedValue(true); + await sut.onBootstrap(ImmichWorker.API); + }); + + it('should update cron job if backup is enabled', () => { + sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + backup: { + database: { + enabled: true, + cronExpression: '0 1 * * *', + }, + }, + } as SystemConfig, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith('backupDatabase', '0 1 * * *', true); + expect(jobMock.updateCronJob).toHaveBeenCalled(); + }); + + it('should do nothing if oldConfig is not provided', () => { + sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should do nothing if instance does not have the backup database lock', async () => { + databaseMock.tryLock.mockResolvedValue(false); + await sut.onBootstrap(ImmichWorker.API); + sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + }); + + describe('onConfigValidateEvent', () => { + it('should allow a valid cron expression', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { backup: { database: { cronExpression: '0 0 * * *' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(expect.stringContaining('Invalid cron expression')); + }); + + it('should fail for an invalid cron expression', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { backup: { database: { cronExpression: 'foo' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).toThrow(/Invalid cron expression.*/); + }); + }); + + 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']); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).not.toHaveBeenCalled(); + }); + + it('should remove failed backup files', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.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( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, + ); + expect(storageMock.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']); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).toHaveBeenCalledTimes(1); + expect(storageMock.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([ + '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( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, + ); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, + ); + }); + }); + + describe('handleBackupDatabase', () => { + beforeEach(() => { + storageMock.readdir.mockResolvedValue([]); + processMock.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + storageMock.rename.mockResolvedValue(); + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.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(); + }); + it('should rename file on success', async () => { + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.SUCCESS); + expect(storageMock.rename).toHaveBeenCalled(); + }); + it('should fail if pg_dumpall fails', async () => { + processMock.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')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + expect(storageMock.rename).not.toHaveBeenCalled(); + }); + it('should fail if gzip fails', async () => { + processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); + processMock.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(() => { + 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')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + }); + }); +}); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts new file mode 100644 index 0000000000..ba2ab816cd --- /dev/null +++ b/server/src/services/backup.service.ts @@ -0,0 +1,157 @@ +import { Injectable } from '@nestjs/common'; +import { default as path } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } 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 } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; +import { handlePromiseError } from 'src/utils/misc'; +import { validateCronExpression } from 'src/validation'; + +@Injectable() +export class BackupService extends BaseService { + private backupLock = false; + + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(workerType: ImmichWorker) { + if (workerType !== ImmichWorker.API) { + return; + } + const { + backup: { database }, + } = await this.getConfig({ withCache: true }); + + this.backupLock = await this.databaseRepository.tryLock(DatabaseLock.BackupDatabase); + + if (this.backupLock) { + this.jobRepository.addCronJob( + 'backupDatabase', + database.cronExpression, + () => handlePromiseError(this.jobRepository.queue({ name: JobName.BACKUP_DATABASE }), this.logger), + database.enabled, + ); + } + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { backup }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.backupLock) { + return; + } + + this.jobRepository.updateCronJob('backupDatabase', backup.database.cronExpression, backup.database.enabled); + } + + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { + const { database } = newConfig.backup; + if (!validateCronExpression(database.cronExpression)) { + throw new Error(`Invalid cron expression ${database.cronExpression}`); + } + } + + async cleanupDatabaseBackups() { + this.logger.debug(`Database Backup Cleanup Started`); + const { + backup: { database: config }, + } = await this.getConfig({ withCache: false }); + + const backupsFolder = StorageCore.getBaseFolder(StorageFolder.BACKUPS); + const files = await this.storageRepository.readdir(backupsFolder); + const failedBackups = files.filter((file) => file.match(/immich-db-backup-\d+\.sql\.gz\.tmp$/)); + const backups = files + .filter((file) => file.match(/immich-db-backup-\d+\.sql\.gz$/)) + .sort() + .reverse(); + + const toDelete = backups.slice(config.keepLastAmount); + toDelete.push(...failedBackups); + + for (const file of toDelete) { + await this.storageRepository.unlink(path.join(backupsFolder, file)); + } + this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); + } + + async handleBackupDatabase(): Promise { + this.logger.debug(`Database Backup Started`); + + const { + database: { config }, + } = this.configRepository.getEnv(); + + const isUrlConnection = config.connectionType === 'url'; + const databaseParams = isUrlConnection ? [config.url] : ['-U', config.username, '-h', config.host]; + const backupFilePath = path.join( + StorageCore.getBaseFolder(StorageFolder.BACKUPS), + `immich-db-backup-${Date.now()}.sql.gz.tmp`, + ); + + try { + await new Promise((resolve, reject) => { + const pgdump = this.processRepository.spawn(`pg_dumpall`, [...databaseParams, '--clean', '--if-exists'], { + env: { PATH: process.env.PATH, PGPASSWORD: isUrlConnection ? undefined : config.password }, + }); + + const gzip = this.processRepository.spawn(`gzip`, []); + pgdump.stdout.pipe(gzip.stdin); + + const fileStream = this.storageRepository.createWriteStream(backupFilePath); + + gzip.stdout.pipe(fileStream); + + pgdump.on('error', (err) => { + this.logger.error('Backup failed with error', err); + reject(err); + }); + + gzip.on('error', (err) => { + this.logger.error('Gzip failed with error', err); + reject(err); + }); + + let pgdumpLogs = ''; + let gzipLogs = ''; + + pgdump.stderr.on('data', (data) => (pgdumpLogs += data)); + gzip.stderr.on('data', (data) => (gzipLogs += data)); + + pgdump.on('exit', (code) => { + if (code !== 0) { + this.logger.error(`Backup failed with code ${code}`); + reject(`Backup failed with code ${code}`); + this.logger.error(pgdumpLogs); + return; + } + if (pgdumpLogs) { + this.logger.debug(`pgdump_all logs\n${pgdumpLogs}`); + } + }); + + gzip.on('exit', (code) => { + if (code !== 0) { + this.logger.error(`Gzip failed with code ${code}`); + reject(`Gzip failed with code ${code}`); + this.logger.error(gzipLogs); + return; + } + if (pgdump.exitCode !== 0) { + this.logger.error(`Gzip exited with code 0 but pgdump exited with ${pgdump.exitCode}`); + return; + } + resolve(); + }); + }); + await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', '')); + } catch (error) { + this.logger.error('Database Backup Failure', error); + return JobStatus.FAILED; + } + + this.logger.debug(`Database Backup Success`); + await this.cleanupDatabaseBackups(); + return JobStatus.SUCCESS; + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts new file mode 100644 index 0000000000..a312050f08 --- /dev/null +++ b/server/src/services/base.service.ts @@ -0,0 +1,151 @@ +import { BadRequestException, Inject } from '@nestjs/common'; +import sanitize from 'sanitize-filename'; +import { SystemConfig } from 'src/config'; +import { SALT_ROUNDS } from 'src/constants'; +import { StorageCore } from 'src/cores/storage.core'; +import { UserEntity } from 'src/entities/user.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IKeyRepository } from 'src/interfaces/api-key.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IConfigRepository } from 'src/interfaces/config.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 { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; +import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { IMoveRepository } from 'src/interfaces/move.interface'; +import { INotificationRepository } from 'src/interfaces/notification.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.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 { IServerInfoRepository } from 'src/interfaces/server-info.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 { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; +import { getConfig, updateConfig } from 'src/utils/config'; + +export class BaseService { + protected storageCore: StorageCore; + + constructor( + @Inject(ILoggerRepository) protected logger: ILoggerRepository, + @Inject(IAccessRepository) protected accessRepository: IAccessRepository, + @Inject(IActivityRepository) protected activityRepository: IActivityRepository, + @Inject(IAuditRepository) protected auditRepository: IAuditRepository, + @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, + @Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository, + @Inject(IAssetRepository) protected assetRepository: IAssetRepository, + @Inject(IConfigRepository) protected configRepository: IConfigRepository, + @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, + @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, + @Inject(IEventRepository) protected eventRepository: IEventRepository, + @Inject(IJobRepository) protected jobRepository: IJobRepository, + @Inject(IKeyRepository) protected keyRepository: IKeyRepository, + @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, + @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, + @Inject(IMapRepository) protected mapRepository: IMapRepository, + @Inject(IMediaRepository) protected mediaRepository: IMediaRepository, + @Inject(IMemoryRepository) protected memoryRepository: IMemoryRepository, + @Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository, + @Inject(IMoveRepository) protected moveRepository: IMoveRepository, + @Inject(INotificationRepository) protected notificationRepository: INotificationRepository, + @Inject(IOAuthRepository) protected oauthRepository: IOAuthRepository, + @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, + @Inject(IPersonRepository) protected personRepository: IPersonRepository, + @Inject(IProcessRepository) protected processRepository: IProcessRepository, + @Inject(ISearchRepository) protected searchRepository: ISearchRepository, + @Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository, + @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, + @Inject(ITelemetryRepository) protected telemetryRepository: ITelemetryRepository, + @Inject(ITrashRepository) protected trashRepository: ITrashRepository, + @Inject(IUserRepository) protected userRepository: IUserRepository, + @Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository, + @Inject(IViewRepository) protected viewRepository: IViewRepository, + ) { + this.logger.setContext(this.constructor.name); + this.storageCore = StorageCore.create( + assetRepository, + configRepository, + cryptoRepository, + moveRepository, + personRepository, + storageRepository, + systemMetadataRepository, + this.logger, + ); + } + + private get configRepos() { + return { + configRepo: this.configRepository, + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }; + } + + getConfig(options: { withCache: boolean }) { + return getConfig(this.configRepos, options); + } + + updateConfig(newConfig: SystemConfig) { + return updateConfig(this.configRepos, newConfig); + } + + requireAccess(request: AccessRequest) { + return requireAccess(this.accessRepository, request); + } + + checkAccess(request: AccessRequest) { + return checkAccess(this.accessRepository, request); + } + + async createUser(dto: Partial & { email: string }): Promise { + const user = await this.userRepository.getByEmail(dto.email); + if (user) { + throw new BadRequestException('User exists'); + } + + if (!dto.isAdmin) { + const localAdmin = await this.userRepository.getAdmin(); + if (!localAdmin) { + throw new BadRequestException('The first registered account must the administrator.'); + } + } + + const payload: Partial = { ...dto }; + if (payload.password) { + payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); + } + if (payload.storageLabel) { + payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); + } + + return this.userRepository.create(payload); + } +} diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index e52c648664..ef520070ea 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,30 +1,26 @@ -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; 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 { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, describe, it } from 'vitest'; describe(CliService.name, () => { let sut: CliService; let userMock: Mocked; - let cryptoMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - cryptoMock = newCryptoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); + ({ sut, userMock, systemMock } = newTestService(CliService)); + }); - sut = new CliService(cryptoMock, systemMock, userMock, loggerMock); + describe('listUsers', () => { + it('should list users', async () => { + userMock.getList.mockResolvedValue([userStub.admin]); + await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + }); }); describe('resetAdminPassword', () => { @@ -65,4 +61,32 @@ describe(CliService.name, () => { expect(update.password).toBeDefined(); }); }); + + describe('disablePasswordLogin', () => { + it('should disable password login', async () => { + await sut.disablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + }); + }); + + describe('enablePasswordLogin', () => { + it('should enable password login', async () => { + await sut.enablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('disableOAuthLogin', () => { + it('should disable oauth login', async () => { + await sut.disableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('enableOAuthLogin', () => { + it('should enable oauth login', async () => { + await sut.enableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + }); + }); }); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 1c25c306b6..18a79108c4 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,26 +1,10 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class CliService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(CliService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class CliService extends BaseService { async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); return users.map((user) => mapUserAdmin(user)); @@ -42,26 +26,26 @@ export class CliService { } async disablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async disableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } } diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index df3a9798ef..958fb158a0 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,295 +1,414 @@ -import { DatabaseExtension, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { + DatabaseExtension, + EXTENSION_NAMES, + IDatabaseRepository, + VectorExtension, +} from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(DatabaseService.name, () => { let sut: DatabaseService; + + let configMock: Mocked; let databaseMock: Mocked; let loggerMock: Mocked; + let extensionRange: string; + let versionBelowRange: string; + let minVersionInRange: string; + let updateInRange: string; + let versionAboveRange: string; beforeEach(() => { - delete process.env.DB_SKIP_MIGRATIONS; - delete process.env.DB_VECTOR_EXTENSION; - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new DatabaseService(databaseMock, loggerMock); + ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); - databaseMock.getExtensionVersion.mockResolvedValue('0.2.0'); + extensionRange = '0.2.x'; + databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); + + versionBelowRange = '0.1.0'; + minVersionInRange = '0.2.0'; + updateInRange = '0.2.1'; + versionAboveRange = '0.3.0'; + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: minVersionInRange, + availableVersion: minVersionInRange, + }); }); it('should work', () => { expect(sut).toBeDefined(); }); - it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + describe('onBootstrap', () => { + it('should throw an error if PostgreSQL version is below minimum supported version', async () => { + databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrapEvent()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); - }); - - it(`should start up successfully with pgvectors`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTORS); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it(`should start up successfully with pgvector`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTOR); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it(`should throw an error if the pgvecto.rs extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue(''); - await expect(sut.onBootstrapEvent()).rejects.toThrow(`Unexpected: The pgvecto.rs extension is not installed.`); - - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw an error if the pgvector extension is not installed`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue(''); - await expect(sut.onBootstrapEvent()).rejects.toThrow(`Unexpected: The pgvector extension is not installed.`); - - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw an error if the pgvecto.rs extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue('0.1.0'); - - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvecto.rs extension version is 0.1.0, but Immich only supports 0.2.x.', - ); - - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw an error if the pgvector extension version is below minimum supported version`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.1.0'); - - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvector extension version is 0.1.0, but Immich only supports >=0.5 <1', - ); - - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw an error if pgvecto.rs extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); - - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvecto.rs extension version is 0.0.0, which means it is a nightly release.', - ); - - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw an error if pgvector extension version is a nightly`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); - - await expect(sut.onBootstrapEvent()).rejects.toThrow( - 'The pgvector extension version is 0.0.0, which means it is a nightly release.', - ); - - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.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.runMigrations).not.toHaveBeenCalled(); - }); - - it(`should throw error if pgvector extension could not be created`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.0.0'); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.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.runMigrations).not.toHaveBeenCalled(); - }); - - for (const version of ['0.2.1', '0.2.0', '0.2.9']) { - it(`should update the pgvecto.rs extension to ${version}`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - databaseMock.getExtensionVersion.mockResolvedValueOnce(void 0); - databaseMock.getExtensionVersion.mockResolvedValue(version); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', version); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - } - for (const version of ['0.5.1', '0.6.0', '0.7.10']) { - it(`should update the pgvectors extension to ${version}`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); - databaseMock.getExtensionVersion.mockResolvedValueOnce(void 0); - databaseMock.getExtensionVersion.mockResolvedValue(version); + describe.each(>[ + { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, + { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, + ])('should work with $extensionName', ({ extension, extensionName }) => { + beforeEach(() => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + config: { + connectionType: 'parts', + type: 'postgres', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', + }, + skipMigrations: false, + vectorExtension: extension, + }, + }), + ); + }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + it(`should start up successfully with ${extension}`, async () => { + databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', version); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + 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(); + }); + + it(`should throw an error if the ${extension} extension is not installed`, async () => { + databaseMock.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(); + }); + + it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: versionBelowRange, + availableVersion: versionBelowRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, + ); + + expect(databaseMock.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' }); + + 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(); + }); + + it(`should do in-range update for ${extension} extension`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.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(); + }); + + it(`should not upgrade ${extension} if same version`, async () => { + databaseMock.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(); + }); + + it(`should throw error if ${extension} available version is below range`, async () => { + databaseMock.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(); + }); + + it(`should throw error if ${extension} available version is above range`, async () => { + databaseMock.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(); + }); + + it('should throw error if available version is below installed version', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: updateInRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `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(); + }); + + it('should throw error if installed version is not in version range', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: versionAboveRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `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(); + }); + + it(`should raise error if ${extension} extension upgrade failed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.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( + `The ${extensionName} extension can be updated to ${updateInRange}.`, + ); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + + it(`should warn if ${extension} extension update requires restart`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.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(); + }); + + it(`should reindex ${extension} indices if needed`, async () => { + databaseMock.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(); + }); + + it(`should throw an error if reindexing fails`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + databaseMock.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.stringContaining('Could not run vector reindexing checks.'), + ); + }); + + it(`should not reindex ${extension} indices if not needed`, async () => { + databaseMock.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(); + }); }); - } - for (const version of ['0.1.0', '0.3.0', '1.0.0']) { - it(`should not upgrade pgvecto.rs to ${version}`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); + it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + config: { + connectionType: 'parts', + type: 'postgres', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', + }, + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTORS, + }, + }), + ); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + + it(`should throw error if pgvector extension could not be created`, async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + config: { + connectionType: 'parts', + type: 'postgres', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', + }, + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTOR, + }, + }), + ); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.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( + `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).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - } - for (const version of ['0.4.0', '0.7.1', '0.7.2', '1.0.0']) { - it(`should not upgrade pgvector to ${version}`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.7.2'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue(version); + it(`should throw error if pgvecto.rs extension could not be created`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.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).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - } - - it(`should warn if the pgvecto.rs extension upgrade failed`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.5.2'); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(loggerMock.warn.mock.calls[0][0]).toContain('The pgvector extension can be updated to 0.5.2.'); - expect(loggerMock.error).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', '0.5.2'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); }); - it(`should warn if the pgvector extension upgrade failed`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.2.1'); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + describe('handleConnectionError', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + afterAll(() => { + vi.useRealTimers(); + }); - expect(loggerMock.warn.mock.calls[0][0]).toContain('The pgvecto.rs extension can be updated to 0.2.1.'); - expect(loggerMock.error).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', '0.2.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - }); + it('should not override interval', () => { + sut.handleConnectionError(new Error('Error')); + expect(loggerMock.error).toHaveBeenCalled(); - it(`should warn if the pgvecto.rs extension update requires restart`, async () => { - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.2.1'); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + sut.handleConnectionError(new Error('foo')); + expect(loggerMock.error).toHaveBeenCalledTimes(1); + }); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + it('should reconnect when interval elapses', async () => { + databaseMock.reconnect.mockResolvedValue(true); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain('pgvecto.rs'); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vectors', '0.2.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); - it(`should warn if the pgvector extension update requires restart`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; - databaseMock.getExtensionVersion.mockResolvedValue('0.5.0'); - databaseMock.getAvailableExtensionVersion.mockResolvedValue('0.5.1'); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + }); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain('pgvector'); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith('vector', '0.5.1'); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + it('should try again when reconnection fails', async () => { + databaseMock.reconnect.mockResolvedValueOnce(false); - it('should reindex if needed', async () => { - databaseMock.shouldReindex.mockResolvedValue(true); + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it('should not reindex if not needed', async () => { - databaseMock.shouldReindex.mockResolvedValue(false); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); - - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - process.env.DB_SKIP_MIGRATIONS = 'true'; - databaseMock.getExtensionVersion.mockResolvedValue('0.2.0'); - - await expect(sut.onBootstrapEvent()).resolves.toBeUndefined(); - - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + databaseMock.reconnect.mockResolvedValueOnce(true); + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + }); }); }); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index e50a509dbf..363266c6ae 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,150 +1,179 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { Duration } from 'luxon'; import semver from 'semver'; -import { POSTGRES_VERSION_RANGE, VECTORS_VERSION_RANGE, VECTOR_VERSION_RANGE } from 'src/constants'; -import { getVectorExtension } from 'src/database.config'; -import { EventHandlerOptions } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, EXTENSION_NAMES, - IDatabaseRepository, + VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { BaseService } from 'src/services/base.service'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; type RestartRequiredArgs = { name: string; availableVersion: string }; type NightlyVersionArgs = { name: string; extension: string; version: string }; type OutOfRangeArgs = { name: string; extension: string; version: string; range: string }; - -const EXTENSION_RANGES = { - [DatabaseExtension.VECTOR]: VECTOR_VERSION_RANGE, - [DatabaseExtension.VECTORS]: VECTORS_VERSION_RANGE, -}; +type InvalidDowngradeArgs = { name: string; extension: string; installedVersion: string; availableVersion: string }; const messages = { - notInstalled: (name: string) => `Unexpected: The ${name} extension is not installed.`, + notInstalled: (name: string) => + `The ${name} extension is not available in this Postgres instance. + If using a container image, ensure the image has the extension installed.`, nightlyVersion: ({ name, extension, version }: NightlyVersionArgs) => ` - The ${name} extension version is ${version}, which means it is a nightly release. + The ${name} extension version is ${version}, which means it is a nightly release. - Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version. - See https://immich.app/docs/guides/database-queries for how to query the database.`, - outOfRange: ({ name, extension, version, range }: OutOfRangeArgs) => ` - The ${name} extension version is ${version}, but Immich only supports ${range}. + Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version. + See https://immich.app/docs/guides/database-queries for how to query the database.`, + outOfRange: ({ name, version, range }: OutOfRangeArgs) => + `The ${name} extension version is ${version}, but Immich only supports ${range}. + Please change ${name} to a compatible version in the Postgres instance.`, + createFailed: ({ name, extension, otherName }: CreateFailedArgs) => + `Failed to activate ${name} extension. + Please ensure the Postgres instance has ${name} installed. - If the Postgres instance already has a compatible version installed, Immich may not have the necessary permissions to activate it. - In this case, please run 'ALTER EXTENSION UPDATE ${extension}' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database. + If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it. + In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser. + See https://immich.app/docs/guides/database-queries for how to query the database. - Otherwise, please update the version of ${name} in the Postgres instance to a compatible version.`, - createFailed: ({ name, extension, otherName }: CreateFailedArgs) => ` - Failed to activate ${name} extension. - Please ensure the Postgres instance has ${name} installed. + Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'. + Note that switching between the two extensions after a successful startup is not supported. + The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. + In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.`, + updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) => + `The ${name} extension can be updated to ${availableVersion}. + Immich attempted to update the extension, but failed to do so. + This may be because Immich does not have the necessary permissions to update the extension. - If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it. - In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database. - - Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'. - Note that switching between the two extensions after a successful startup is not supported. - The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. - In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup. - `, - updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) => ` - The ${name} extension can be updated to ${availableVersion}. - Immich attempted to update the extension, but failed to do so. - This may be because Immich does not have the necessary permissions to update the extension. - - Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser. - See https://immich.app/docs/guides/database-queries for how to query the database.`, - restartRequired: ({ name, availableVersion }: RestartRequiredArgs) => ` - The ${name} extension has been updated to ${availableVersion}. - Please restart the Postgres instance to complete the update.`, + Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser. + See https://immich.app/docs/guides/database-queries for how to query the database.`, + restartRequired: ({ name, availableVersion }: RestartRequiredArgs) => + `The ${name} extension has been updated to ${availableVersion}. + Please restart the Postgres instance to complete the update.`, + invalidDowngrade: ({ name, installedVersion, availableVersion }: InvalidDowngradeArgs) => + `The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available. + This most likely means the extension was downgraded. + If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, }; -@Injectable() -export class DatabaseService implements OnEvents { - constructor( - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(DatabaseService.name); - } +const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); - @EventHandlerOptions({ priority: -200 }) - async onBootstrapEvent() { +@Injectable() +export class DatabaseService extends BaseService { + private reconnection?: NodeJS.Timeout; + + @OnEvent({ name: 'app.bootstrap', priority: -200 }) + async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); - if (!current || !semver.satisfies(current, POSTGRES_VERSION_RANGE)) { + const postgresRange = this.databaseRepository.getPostgresVersionRange(); + if (!current || !semver.satisfies(current, postgresRange)) { throw new Error( - `Invalid PostgreSQL version. Found ${version}, but needed ${POSTGRES_VERSION_RANGE}. Please use a supported version.`, + `Invalid PostgreSQL version. Found ${version}, but needed ${postgresRange}. Please use a supported version.`, ); } await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => { - const extension = getVectorExtension(); - const otherExtension = - extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; - const otherName = EXTENSION_NAMES[otherExtension]; + const envData = this.configRepository.getEnv(); + const extension = envData.database.vectorExtension; const name = EXTENSION_NAMES[extension]; - const extensionRange = EXTENSION_RANGES[extension]; + const extensionRange = this.databaseRepository.getExtensionVersionRange(extension); - try { - await this.databaseRepository.createExtension(extension); - } catch (error) { - this.logger.fatal(messages.createFailed({ name, extension, otherName })); - throw error; - } - - const initialVersion = await this.databaseRepository.getExtensionVersion(extension); - const availableVersion = await this.databaseRepository.getAvailableExtensionVersion(extension); - const isAvailable = availableVersion && semver.satisfies(availableVersion, extensionRange); - if (isAvailable && (!initialVersion || semver.gt(availableVersion, initialVersion))) { - try { - this.logger.log(`Updating ${name} extension to ${availableVersion}`); - const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion); - if (restartRequired) { - this.logger.warn(messages.restartRequired({ name, availableVersion })); - } - } catch (error) { - this.logger.warn(messages.updateFailed({ name, extension, availableVersion })); - this.logger.error(error); - } - } - - const version = await this.databaseRepository.getExtensionVersion(extension); - if (!version) { + const { availableVersion, installedVersion } = await this.databaseRepository.getExtensionVersion(extension); + if (!availableVersion) { throw new Error(messages.notInstalled(name)); } - if (semver.eq(version, '0.0.0')) { - throw new Error(messages.nightlyVersion({ name, extension, version })); + if ([availableVersion, installedVersion].some((version) => version && semver.eq(version, '0.0.0'))) { + throw new Error(messages.nightlyVersion({ name, extension, version: '0.0.0' })); } - if (!semver.satisfies(version, extensionRange)) { - throw new Error(messages.outOfRange({ name, extension, version, range: extensionRange })); + if (!semver.satisfies(availableVersion, extensionRange)) { + throw new Error(messages.outOfRange({ name, extension, version: availableVersion, range: extensionRange })); } - try { - if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) { - await this.databaseRepository.reindex(VectorIndex.CLIP); - } - - if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) { - await this.databaseRepository.reindex(VectorIndex.FACE); - } - } catch (error) { - this.logger.warn( - 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.', - ); - throw error; + if (!installedVersion) { + await this.createExtension(extension); } - if (process.env.DB_SKIP_MIGRATIONS !== 'true') { + if (installedVersion && semver.gt(availableVersion, installedVersion)) { + await this.updateExtension(extension, availableVersion); + } else if (installedVersion && !semver.satisfies(installedVersion, extensionRange)) { + throw new Error(messages.outOfRange({ name, extension, version: installedVersion, range: extensionRange })); + } else if (installedVersion && semver.lt(availableVersion, installedVersion)) { + throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion })); + } + + await this.checkReindexing(); + + const { database } = this.configRepository.getEnv(); + if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } }); } + + 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); + } catch (error) { + const otherExtension = + extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; + const name = EXTENSION_NAMES[extension]; + this.logger.fatal(messages.createFailed({ name, extension, otherName: EXTENSION_NAMES[otherExtension] })); + throw error; + } + } + + private async updateExtension(extension: VectorExtension, availableVersion: string) { + this.logger.log(`Updating ${EXTENSION_NAMES[extension]} extension to ${availableVersion}`); + try { + const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion); + if (restartRequired) { + this.logger.warn(messages.restartRequired({ name: EXTENSION_NAMES[extension], availableVersion })); + } + } catch (error) { + this.logger.warn(messages.updateFailed({ name: EXTENSION_NAMES[extension], extension, availableVersion })); + throw error; + } + } + + private async checkReindexing() { + try { + if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) { + await this.databaseRepository.reindex(VectorIndex.CLIP); + } + + if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) { + await this.databaseRepository.reindex(VectorIndex.FACE); + } + } catch (error) { + this.logger.warn( + 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.', + ); + throw error; + } + } } diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 6216a4dc3a..632d157384 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -2,13 +2,13 @@ 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Mocked, vitest } from 'vitest'; @@ -26,6 +26,7 @@ describe(DownloadService.name, () => { let sut: DownloadService; let accessMock: IAccessRepositoryMock; let assetMock: Mocked; + let loggerMock: Mocked; let storageMock: Mocked; it('should work', () => { @@ -33,14 +34,54 @@ describe(DownloadService.name, () => { }); beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - storageMock = newStorageRepositoryMock(); - - sut = new DownloadService(accessMock, assetMock, storageMock); + ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService)); }); describe('downloadArchive', () => { + it('should skip asset ids that could not be found', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + 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); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(1); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + }); + + it('should log a warning if the original path could not be resolved', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + 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([ + { ...assetStub.noResizePath, id: 'asset-1' }, + { ...assetStub.noWebpPath, id: 'asset-2' }, + ]); + storageMock.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(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'); + }); + it('should download an archive', async () => { const archiveMock = { addFile: vitest.fn(), @@ -109,6 +150,27 @@ describe(DownloadService.name, () => { expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); }); + + it('should resolve symlinks', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, + ]); + storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg'); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg'); + }); }); describe('getDownloadInfo', () => { @@ -201,5 +263,31 @@ describe(DownloadService.name, () => { ], }); }); + + 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' }, + ]; + + 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, + ); + + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 25_000, + archives: [ + { + assetIds: [assetStub.livePhotoStillAsset.id], + size: 25_000, + }, + ], + }); + }); }); }); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 07ef03efb5..3d66f009cf 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,39 +1,40 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; -import { AccessCore, Permission } from 'src/cores/access.core'; +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 { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface'; +import { Permission } from 'src/enum'; +import { ImmichReadStream } from 'src/interfaces/storage.interface'; +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 { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - +export class DownloadService extends BaseService { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + const preferences = getPreferences(auth.user); + 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); + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true }))); + 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) { @@ -60,7 +61,7 @@ export class DownloadService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -83,7 +84,14 @@ export class DownloadService { filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } - zip.addFile(originalPath, filename); + let realpath = originalPath; + try { + realpath = await this.storageRepository.realpath(originalPath); + } catch { + this.logger.warn('Unable to resolve realpath', { originalPath }); + } + + zip.addFile(realpath, filename); } void zip.finalize(); @@ -96,20 +104,20 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, 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.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, 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.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); + await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index eada2fffcf..095d53dde6 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,5 +1,4 @@ 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -7,40 +6,38 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newTestService } from 'test/utils'; import { Mocked, beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: DuplicateService; + let assetMock: Mocked; - let systemMock: Mocked; - let searchMock: Mocked; - let loggerMock: Mocked; - let cryptoMock: Mocked; let jobMock: Mocked; + let loggerMock: Mocked; + let searchMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - - sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock); + ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('getDuplicates', () => { + it('should get duplicates', async () => { + assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]); + await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ + { duplicateId: assetStub.hasDupe.duplicateId, assets: [expect.objectContaining({ id: assetStub.hasDupe.id })] }, + ]); + }); + }); + describe('handleQueueSearchDuplicates', () => { beforeEach(() => { systemMock.get.mockResolvedValue({ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index ae9d101c58..e76b80b043 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,49 +1,26 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { Injectable } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { - IBaseJob, - IEntityJob, - IJobRepository, - JOBS_ASSET_PAGINATION_SIZE, - JobName, - JobStatus, -} from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetDuplicateResult } from 'src/interfaces/search.interface'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class DuplicateService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - ) { - this.logger.setContext(DuplicateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class DuplicateService extends BaseService { async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth }))); + return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))); } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -64,12 +41,12 @@ export class DuplicateService { } async handleSearchDuplicates({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } - const asset = await this.assetRepository.getById(id, { smartSearch: true }); + const asset = await this.assetRepository.getById(id, { files: true, smartSearch: true }); if (!asset) { this.logger.error(`Asset ${id} not found`); return JobStatus.FAILED; @@ -80,7 +57,8 @@ export class DuplicateService { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { this.logger.warn(`Asset ${id} is missing preview image`); return JobStatus.FAILED; } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ab680f15e3..89c6afd7f4 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -6,6 +6,7 @@ import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; +import { BackupService } from 'src/services/backup.service'; import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; @@ -25,6 +26,7 @@ import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; +import { StackService } from 'src/services/stack.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; @@ -36,6 +38,7 @@ import { TrashService } from 'src/services/trash.service'; import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; +import { ViewService } from 'src/services/view.service'; export const services = [ APIKeyService, @@ -46,6 +49,7 @@ export const services = [ AssetService, AuditService, AuthService, + BackupService, CliService, DatabaseService, DownloadService, @@ -65,6 +69,7 @@ export const services = [ SessionService, SharedLinkService, SmartInfoService, + StackService, StorageService, StorageTemplateService, SyncService, @@ -76,4 +81,5 @@ export const services = [ UserAdminService, UserService, VersionService, + ViewService, ]; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb4..8e42693dc0 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,8 +1,7 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; +import { ImmichWorker } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobCommand, @@ -12,19 +11,10 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { JobService } from 'src/services/job.service'; import { assetStub } from 'test/fixtures/asset.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; const makeMockHandlers = (status: JobStatus) => { @@ -38,28 +28,30 @@ const makeMockHandlers = (status: JobStatus) => { describe(JobService.name, () => { let sut: JobService; let assetMock: Mocked; - let eventMock: Mocked; let jobMock: Mocked; - let personMock: Mocked; - let metricMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - personMock = newPersonRepositoryMock(); - metricMock = newMetricRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock); + ({ sut, assetMock, jobMock, systemMock } = newTestService(JobService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap(ImmichWorker.MICROSERVICES); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + 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); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -122,6 +114,7 @@ describe(JobService.name, () => { [QueueName.SIDECAR]: expectedJobStatus, [QueueName.LIBRARY]: expectedJobStatus, [QueueName.NOTIFICATION]: expectedJobStatus, + [QueueName.BACKUP_DATABASE]: expectedJobStatus, }); }); }); @@ -239,36 +232,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, @@ -288,7 +251,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +262,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +289,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +312,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f232c4ac77..15046a0ef5 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,14 +1,12 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; +import { ArgOf } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, - IJobRepository, JobCommand, JobHandler, JobItem, @@ -17,26 +15,56 @@ import { QueueCleanType, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; + +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; @Injectable() -export class JobService { - private configCore: SystemConfigCore; +export class JobService extends BaseService { + private isMicroservices = false; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IMetricRepository) private metricRepository: IMetricRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === ImmichWorker.MICROSERVICES; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + + async create(dto: JobCreateDto): Promise { + await this.jobRepository.queue(asJobItem(dto)); } async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { @@ -96,7 +124,7 @@ export class JobService { throw new BadRequestException(`Job is already running`); } - this.metricRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1); + this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1); switch (name) { case QueueName.VIDEO_CONVERSION: { @@ -140,7 +168,7 @@ export class JobService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); } default: { @@ -150,7 +178,7 @@ export class JobService { } async init(jobHandlers: Record) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); for (const queueName of Object.values(QueueName)) { let concurrency = 1; @@ -162,36 +190,29 @@ export class JobService { this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise => { const { name, data } = item; + const handler = jobHandlers[name]; + if (!handler) { + this.logger.warn(`Skipping unknown job: "${name}"`); + return; + } + const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; - this.metricRepository.jobs.addToGauge(queueMetric, 1); + this.telemetryRepository.jobs.addToGauge(queueMetric, 1); try { - const handler = jobHandlers[name]; const status = await handler(data); const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`; - this.metricRepository.jobs.addToCounter(jobMetric, 1); + this.telemetryRepository.jobs.addToCounter(jobMetric, 1); if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) { await this.onDone(item); } } catch (error: Error | any) { this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data); } finally { - this.metricRepository.jobs.addToGauge(queueMetric, -1); + this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { @@ -199,6 +220,7 @@ export class JobService { QueueName.FACIAL_RECOGNITION, QueueName.STORAGE_TEMPLATE_MIGRATION, QueueName.DUPLICATE_DETECTION, + QueueName.BACKUP_DATABASE, ].includes(name); } @@ -231,13 +253,14 @@ export class JobService { name: JobName.METADATA_EXTRACTION, data: { id: item.data.id, source: 'sidecar-write' }, }); + break; } case JobName.METADATA_EXTRACTION: { if (item.data.source === 'sidecar-write') { const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); if (asset) { - this.eventRepository.clientSend(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); } } await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); @@ -251,7 +274,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -260,45 +283,38 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.eventRepository.clientSend(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id); + this.eventRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); } break; } - case JobName.GENERATE_PREVIEW: { - const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, - ]; - - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } - } - - await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (item.data.source !== 'upload') { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { break; } const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); - - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { - this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; } + + const jobs: JobItem[] = [ + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, + ]; + + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); + } + + await this.jobRepository.queueAll(jobs); + if (asset.isVisible) { + this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); + } + break; } @@ -310,7 +326,7 @@ export class JobService { } case JobName.USER_DELETION: { - this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id); + this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 4aad2d3d58..a3b270218e 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,16 +1,20 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; -import { AssetType } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetType, ImmichWorker } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { + IJobRepository, + ILibraryAssetJob, + ILibraryFileJob, + JobName, + JOBS_LIBRARY_PAGINATION_SIZE, + JobStatus, +} from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { LibraryService } from 'src/services/library.service'; @@ -19,48 +23,26 @@ 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 { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; +async function* mockWalk() { + yield await Promise.resolve(['/data/user1/photo.jpg']); +} + describe(LibraryService.name, () => { let sut: LibraryService; let assetMock: Mocked; - let systemMock: Mocked; - let cryptoMock: Mocked; + let databaseMock: Mocked; let jobMock: Mocked; let libraryMock: Mocked; let storageMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - systemMock = newSystemMetadataRepositoryMock(); - libraryMock = newLibraryRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - storageMock = newStorageRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new LibraryService( - assetMock, - systemMock, - cryptoMock, - jobMock, - libraryMock, - storageMock, - databaseMock, - loggerMock, - ); + ({ sut, assetMock, databaseMock, jobMock, libraryMock, storageMock, systemMock } = newTestService(LibraryService)); databaseMock.tryLock.mockResolvedValue(true); }); @@ -70,22 +52,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); - await sut.onBootstrapEvent(); - expect(systemMock.get).toHaveBeenCalled(); - expect(jobMock.addCronJob).toHaveBeenCalled(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: false }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); @@ -105,7 +91,7 @@ describe(LibraryService.name, () => { ), ); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(storageMock.watch.mock.calls).toEqual( expect.arrayContaining([ @@ -118,7 +104,7 @@ describe(LibraryService.name, () => { it('should not initialize watcher when watching is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(storageMock.watch).not.toHaveBeenCalled(); }); @@ -127,16 +113,89 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(storageMock.watch).not.toHaveBeenCalled(); }); + + it('should not initialize library scan cron job when lock is taken', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + databaseMock.tryLock.mockResolvedValue(false); + + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + + it('should not initialize watcher or library scan job when running on api', async () => { + await sut.onBootstrap(ImmichWorker.API); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + }); + + describe('onConfigUpdateEvent', () => { + beforeEach(async () => { + systemMock.get.mockResolvedValue(defaults); + databaseMock.tryLock.mockResolvedValue(true); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + }); + + it('should do nothing if oldConfig is not provided', async () => { + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should do nothing if instance does not have the watch lock', async () => { + databaseMock.tryLock.mockResolvedValue(false); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should update cron job and enable watching', async () => { + libraryMock.getAll.mockResolvedValue([]); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchEnabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith( + 'libraryScan', + systemConfigStub.libraryScan.library.scan.cronExpression, + systemConfigStub.libraryScan.library.scan.enabled, + ); + }); + + it('should update cron job and disable watching', async () => { + libraryMock.getAll.mockResolvedValue([]); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchEnabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchDisabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith( + 'libraryScan', + systemConfigStub.libraryScan.library.scan.cronExpression, + systemConfigStub.libraryScan.library.scan.enabled, + ); + }); }); describe('onConfigValidateEvent', () => { it('should allow a valid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -145,7 +204,7 @@ describe(LibraryService.name, () => { it('should fail for an invalid cron expression', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig, oldConfig: {} as SystemConfig, }), @@ -153,63 +212,29 @@ describe(LibraryService.name, () => { }); }); - describe('handleQueueAssetRefresh', () => { - it('should queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - + describe('handleQueueSyncFiles', () => { + it('should queue refresh of a new asset', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); + storageMock.walk.mockImplementation(mockWalk); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibrary1.id, ownerId: libraryStub.externalLibrary1.owner.id, assetPath: '/data/user1/photo.jpg', - force: false, }, }, ]); }); - it('should force queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }; + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield '/data/user1/photo.jpg'; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - force: true, - }, - }, - ]); + await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { @@ -225,69 +250,173 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibraryWithImportPaths1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], exclusionPatterns: [], + includeHidden: false, + take: JOBS_LIBRARY_PAGINATION_SIZE, }); }); - - it('should set missing assets offline', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.external], - hasNextPage: false, - }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should set crawled assets that were previously offline back online', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield assetStub.externalOffline.originalPath; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ - items: [assetStub.externalOffline], - hasNextPage: false, - }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.externalOffline.id], { isOffline: false }); - expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true }); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); }); - describe('handleAssetRefresh', () => { + 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 }); + + await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); + + 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: [], + }; + + assetMock.getById.mockResolvedValue(null); + + 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; beforeEach(() => { @@ -300,42 +429,18 @@ describe(LibraryService.name, () => { } as Stats); }); - it('should reject an unknown file extension', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should reject an unknown file type', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should add a new image', async () => { + it('should import a new asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -370,19 +475,19 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new image with sidecar', async () => { + it('should import a new asset with sidecar', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -417,18 +522,18 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new video', async () => { + it('should import a new video', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/video.mp4', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -460,40 +565,30 @@ describe(LibraryService.name, () => { }, }, ], - [ - { - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.video.id, - }, - }, - ], ]); }); - it('should not add an image to a soft deleted library', async () => { + 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', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); - it('should not import an asset when mtime matches db asset', async () => { + 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, - force: false, }; storageMock.stat.mockResolvedValue({ @@ -504,190 +599,73 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should import an asset when mtime differs from db asset', async () => { + it('should skip existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.image.id, - }, - }); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); - it('should import an asset that is missing a file extension', async () => { - // This tests for the case where the file extension is missing from the asset path. - // This happened in previous versions of Immich + it('should not refresh an asset trashed by user', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: assetStub.missingFileExtension.originalPath, - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith( - [assetStub.missingFileExtension.id], - expect.objectContaining({ originalFileName: 'photo.jpg' }), - ); - }); - - it('should set a missing asset to offline', async () => { - storageMock.stat.mockRejectedValue(new Error('Path not found')); - - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should online a previously-offline asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.offline.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); - assetMock.create.mockResolvedValue(assetStub.offline); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.offline.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.offline.id, - }, - }); - }); - - it('should do nothing when mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - expect(assetMock.update).not.toHaveBeenCalled(); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - }); - - it('should refresh an existing asset if forced', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.hasFileExtension.ownerId, assetPath: assetStub.hasFileExtension.originalPath, - force: true, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - assetMock.create.mockResolvedValue(assetStub.hasFileExtension); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { - fileCreatedAt: new Date('2023-01-01'), - fileModifiedAt: new Date('2023-01-01'), - originalFileName: assetStub.hasFileExtension.originalFileName, - }); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should refresh an existing asset with modified mtime', async () => { - const filemtime = new Date(); - filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); + it('should fail when the file could not be read', async () => { + storageMock.stat.mockRejectedValue(new Error('Could not read file')); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: userStub.admin.id, assetPath: '/data/user1/photo.jpg', - force: false, }; - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: filemtime, - ctime: new Date('2023-01-01'), - } as Stats); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create).toHaveBeenCalled(); - const createdAsset = assetMock.create.mock.calls[0][0]; - - expect(createdAsset.fileModifiedAt).toEqual(filemtime); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + expect(libraryMock.get).not.toHaveBeenCalled(); + expect(assetMock.create).not.toHaveBeenCalled(); }); - it('should throw error when asset does not exist', async () => { - storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); + 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', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + expect(libraryMock.get).not.toHaveBeenCalled(); + expect(assetMock.create).not.toHaveBeenCalled(); }); }); @@ -730,7 +708,7 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); @@ -760,7 +738,6 @@ describe(LibraryService.name, () => { describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, @@ -771,6 +748,10 @@ describe(LibraryService.name, () => { 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); + }); }); describe('create', () => { @@ -795,7 +776,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -820,7 +801,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: 'My Awesome Library', importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -850,7 +831,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -861,7 +842,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.create({ ownerId: authStub.admin.user.id, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, @@ -900,6 +881,13 @@ 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 })]); + }); + }); + describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); @@ -917,23 +905,48 @@ describe(LibraryService.name, () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + }); + + it('should throw an error if an import path is invalid', async () => { + libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); + expect(libraryMock.update).not.toHaveBeenCalled(); }); it('should update library', async () => { libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.externalLibrary1)); + storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + storageMock.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(expect.objectContaining({ id: 'library-id' })); }); }); + describe('onShutdown', () => { + it('should do nothing if instance does not have the watch lock', async () => { + await sut.onShutdown(); + }); + }); + describe('watchAll', () => { + it('should return false if instance does not have the watch lock', async () => { + await expect(sut.watchAll()).resolves.toBe(false); + }); + describe('watching disabled', () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); }); it('should not watch library', async () => { @@ -949,7 +962,7 @@ describe(LibraryService.name, () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); - await sut.onBootstrapEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); }); it('should watch library', async () => { @@ -989,26 +1002,30 @@ describe(LibraryService.name, () => { 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' }] })); await sut.watchAll(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); + 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( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); @@ -1017,28 +1034,32 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); + 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.external); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); storageMock.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, + ]); }); it('should handle an error event', async () => { @@ -1107,35 +1128,25 @@ describe(LibraryService.name, () => { const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); - await sut.onBootstrapEvent(); - await sut.onShutdownEvent(); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + await sut.onShutdown(); expect(mockClose).toHaveBeenCalledTimes(2); }); }); describe('handleDeleteLibrary', () => { - it('should not delete a nonexistent library', async () => { - libraryMock.get.mockResolvedValue(null); - - libraryMock.getAssetIds.mockResolvedValue([]); - libraryMock.delete.mockImplementation(async () => {}); - - await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.FAILED); - }); - it('should delete an empty library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([]); - libraryMock.delete.mockImplementation(async () => {}); + assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + expect(libraryMock.delete).toHaveBeenCalled(); }); - it('should delete a library with assets', async () => { + it('should delete all assets in a library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); - libraryMock.delete.mockImplementation(async () => {}); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); @@ -1144,72 +1155,23 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('should queue a library scan of external library', async () => { + it('should queue a library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, }, }, ], - ]); - }); - - it('should queue a library scan of all modified assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ], - ]); - }); - - it('should queue a forced library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }, - }, - ], - ]); - }); - }); - - describe('queueEmptyTrash', () => { - it('should queue the trash job', async () => { - await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_REMOVE_OFFLINE, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: libraryStub.externalLibrary1.id, }, @@ -1223,7 +1185,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1235,53 +1197,41 @@ describe(LibraryService.name, () => { ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ]); - }); - - it('should queue the force refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, }, }, ]); }); }); - describe('handleRemoveOfflineFiles', () => { - it('should queue trash deletion jobs', async () => { - assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + 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.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.image1.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, + }, + }, ]); }); }); describe('validate', () => { + it('should not require import paths', async () => { + await expect(sut.validate('library-id', {})).resolves.toEqual({ importPaths: [] }); + }); + it('should validate directory', async () => { storageMock.stat.mockResolvedValue({ isDirectory: () => true, @@ -1367,14 +1317,31 @@ describe(LibraryService.name, () => { }); }); + it('should detect when import path is not absolute', async () => { + const cwd = process.cwd(); + + await expect(sut.validate('library-id', { importPaths: ['relative/path'] })).resolves.toEqual({ + importPaths: [ + { + importPath: 'relative/path', + isValid: false, + message: `Import path must be absolute, try ${cwd}/relative/path`, + }, + ], + }); + }); + it('should detect when import path is in immich media folder', async () => { storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); - const validImport = libraryStub.hasImmichPaths.importPaths[1]; + const cwd = process.cwd(); + + const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`; storageMock.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport)); - await expect( - sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }), - ).resolves.toEqual({ + const pathStubs = libraryStub.hasImmichPaths.importPaths; + const importPaths = [pathStubs[0], validImport, pathStubs[2]]; + + await expect(sut.validate('library-id', { importPaths })).resolves.toEqual({ importPaths: [ { importPath: libraryStub.hasImmichPaths.importPaths[0], diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 6cded14775..6c329e80ec 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,108 +1,90 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { Trie } from 'mnemonist'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; -import { Stats } from 'node:fs'; -import path, { basename, parse } from 'node:path'; +import path, { basename, isAbsolute, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, + mapLibrary, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, - mapLibrary, } from 'src/dtos/library.dto'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; -import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { AssetType, ImmichWorker } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { - IBaseJob, IEntityJob, - IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryRefreshJob, - JOBS_ASSET_PAGINATION_SIZE, JobName, + JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; -const LIBRARY_SCAN_BATCH_SIZE = 5000; - @Injectable() -export class LibraryService implements OnEvents { - private configCore: SystemConfigCore; +export class LibraryService extends BaseService { private watchLibraries = false; - private watchLock = false; + private lock = false; private watchers: Record Promise> = {}; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILibraryRepository) private repository: ILibraryRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(workerType: ImmichWorker) { + if (workerType !== ImmichWorker.MICROSERVICES) { + return; + } - async onBootstrapEvent() { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { watch, scan } = config.library; // This ensures that library watching only occurs in one microservice - // TODO: we could make the lock be per-library instead of global - this.watchLock = await this.databaseRepository.tryLock(DatabaseLock.LibraryWatch); + this.lock = await this.databaseRepository.tryLock(DatabaseLock.Library); - this.watchLibraries = this.watchLock && watch.enabled; + this.watchLibraries = this.lock && watch.enabled; - this.jobRepository.addCronJob( - 'libraryScan', - scan.cronExpression, - () => - handlePromiseError( - this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), - this.logger, - ), - scan.enabled, - ); + if (this.lock) { + this.jobRepository.addCronJob( + 'libraryScan', + scan.cronExpression, + () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), + scan.enabled, + ); + } if (this.watchLibraries) { await this.watchAll(); } - - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); - - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.lock) { + return; + } + + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } + } + + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { throw new Error(`Invalid cron expression ${scan.cronExpression}`); @@ -143,7 +125,13 @@ export class LibraryService implements OnEvents { const handler = async () => { this.logger.debug(`File add event received for ${path} in library ${library.id}}`); if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); + 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); @@ -151,9 +139,13 @@ export class LibraryService implements OnEvents { 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.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -162,8 +154,8 @@ export class LibraryService implements OnEvents { 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 && matcher(path)) { - await this.assetRepository.update({ id: asset.id, isOffline: true }); + if (asset) { + await this.syncAssets(library, [asset.id]); } }; return handlePromiseError(handler(), this.logger); @@ -187,12 +179,13 @@ export class LibraryService implements OnEvents { } } - async onShutdownEvent() { + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { await this.unwatchAll(); } private async unwatchAll() { - if (!this.watchLock) { + if (!this.lock) { return false; } @@ -202,20 +195,20 @@ export class LibraryService implements OnEvents { } async watchAll() { - if (!this.watchLock) { + if (!this.lock) { return false; } - const libraries = await this.repository.getAll(false); + const libraries = await this.libraryRepository.getAll(false); for (const library of libraries) { await this.watch(library.id); } } async getStatistics(id: string): Promise { - const statistics = await this.repository.getStatistics(id); + const statistics = await this.libraryRepository.getStatistics(id); if (!statistics) { - throw new BadRequestException('Library not found'); + throw new BadRequestException(`Library ${id} not found`); } return statistics; } @@ -226,13 +219,13 @@ export class LibraryService implements OnEvents { } async getAll(): Promise { - const libraries = await this.repository.getAll(false); + const libraries = await this.libraryRepository.getAll(false); return libraries.map((library) => mapLibrary(library)); } async handleQueueCleanup(): Promise { this.logger.debug('Cleaning up any pending library deletions'); - const pendingDeletion = await this.repository.getAllDeleted(); + const pendingDeletion = await this.libraryRepository.getAllDeleted(); await this.jobRepository.queueAll( pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), ); @@ -240,36 +233,35 @@ export class LibraryService implements OnEvents { } async create(dto: CreateLibraryDto): Promise { - const library = await this.repository.create({ + const library = await this.libraryRepository.create({ ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? [], + exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*'], }); return mapLibrary(library); } - private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { - this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`); + private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { + await this.jobRepository.queueAll( + assetPaths.map((assetPath) => ({ + name: JobName.LIBRARY_SYNC_FILE, + data: { + id, + assetPath, + ownerId, + }, + })), + ); + } - // We perform this in batches to save on memory when performing large refreshes (greater than 1M assets) - const batchSize = 5000; - for (let i = 0; i < assetPaths.length; i += batchSize) { - const batch = assetPaths.slice(i, i + batchSize); - await this.jobRepository.queueAll( - batch.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryId, - assetPath: assetPath, - ownerId, - force, - }, - })), - ); - } - - this.logger.debug('Asset refresh queue completed'); + 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 }, + })), + ); } private async validateImportPath(importPath: string): Promise { @@ -281,6 +273,11 @@ export class LibraryService implements OnEvents { return validation; } + if (!isAbsolute(importPath)) { + validation.message = `Import path must be absolute, try ${path.resolve(importPath)}`; + return validation; + } + try { const stat = await this.storageRepository.stat(importPath); if (!stat.isDirectory()) { @@ -316,7 +313,6 @@ export class LibraryService implements OnEvents { async update(id: string, dto: UpdateLibraryDto): Promise { await this.findOrFail(id); - const library = await this.repository.update({ id, ...dto }); if (dto.importPaths) { const validation = await this.validate(id, { importPaths: dto.importPaths }); @@ -329,6 +325,7 @@ export class LibraryService implements OnEvents { } } + const library = await this.libraryRepository.update({ id, ...dto }); return mapLibrary(library); } @@ -339,214 +336,26 @@ export class LibraryService implements OnEvents { await this.unwatch(id); } - await this.repository.softDelete(id); + await this.libraryRepository.softDelete(id); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); } async handleDeleteLibrary(job: IEntityJob): Promise { - const library = await this.repository.get(job.id, true); - if (!library) { - return JobStatus.FAILED; - } + const libraryId = job.id; - // TODO use pagination - const assetIds = await this.repository.getAssetIds(job.id, true); - this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`); - await this.jobRepository.queueAll( - assetIds.map((assetId) => ({ - name: JobName.ASSET_DELETION, - data: { - id: assetId, - deleteOnDisk: false, - }, - })), + const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getAll(pagination, { libraryId, withDeleted: true }), ); - if (assetIds.length === 0) { - this.logger.log(`Deleting library ${job.id}`); - await this.repository.delete(job.id); - } - return JobStatus.SUCCESS; - } - - async handleAssetRefresh(job: ILibraryFileJob): Promise { - const assetPath = path.normalize(job.assetPath); - - const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); - - let stats: Stats; - try { - stats = await this.storageRepository.stat(assetPath); - } catch (error: Error | any) { - // Can't access file, probably offline - if (existingAssetEntity) { - // Mark asset as offline - this.logger.debug(`Marking asset as offline: ${assetPath}`); - - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); - return JobStatus.SUCCESS; - } else { - // File can't be accessed and does not already exist in db - throw new BadRequestException('Cannot access file', { cause: error }); - } - } - - let doImport = false; - let doRefresh = false; - - if (job.force) { - doRefresh = true; - } - - const originalFileName = parse(assetPath).base; - - if (!existingAssetEntity) { - // This asset is new to us, read it from disk - this.logger.debug(`Importing new asset: ${assetPath}`); - doImport = true; - } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { - // File modification time has changed since last time we checked, re-read from disk - this.logger.debug( - `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, - ); - doRefresh = true; - } else if (existingAssetEntity.originalFileName !== originalFileName) { - // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users - this.logger.debug( - `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, - ); - doRefresh = true; - } else if (!job.force && stats && !existingAssetEntity.isOffline) { - // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing - this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); - } - - if (stats && existingAssetEntity?.isOffline) { - // File was previously offline but is now online - this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); - doRefresh = true; - } - - if (!doImport && !doRefresh) { - // If we don't import, exit here - return JobStatus.SKIPPED; - } - - let assetType: AssetType; - - if (mimeTypes.isImage(assetPath)) { - assetType = AssetType.IMAGE; - } else if (mimeTypes.isVideo(assetPath)) { - assetType = AssetType.VIDEO; - } else { - throw new BadRequestException(`Unsupported file type ${assetPath}`); - } - - // TODO: doesn't xmp replace the file extension? Will need investigation - let sidecarPath: string | null = null; - if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { - sidecarPath = `${assetPath}.xmp`; - } - - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); - - let assetId; - if (doImport) { - const library = await this.repository.get(job.id, true); - if (library?.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } - - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - - // TODO: In wait of refactoring the domain asset service, this function is just manually written like this - const addedAsset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, - originalPath: assetPath, - deviceAssetId: deviceAssetId, - deviceId: 'Library Import', - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - localDateTime: stats.mtime, - type: assetType, - originalFileName, - sidecarPath, - isExternal: true, - }); - assetId = addedAsset.id; - } else if (doRefresh && existingAssetEntity) { - assetId = existingAssetEntity.id; - await this.assetRepository.updateAll([existingAssetEntity.id], { - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - originalFileName, - }); - } else { - // Not importing and not refreshing, do nothing - return JobStatus.SKIPPED; - } - - this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); - - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); - - if (assetType === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); - } - - return JobStatus.SUCCESS; - } - - async queueScan(id: string, dto: ScanLibraryDto) { - await this.findOrFail(id); - - await this.jobRepository.queue({ - name: JobName.LIBRARY_SCAN, - data: { - id, - refreshModifiedFiles: dto.refreshModifiedFiles ?? false, - refreshAllFiles: dto.refreshAllFiles ?? false, - }, - }); - } - - async queueRemoveOffline(id: string) { - this.logger.verbose(`Removing offline files from library: ${id}`); - await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); - } - - async handleQueueAllScan(job: IBaseJob): Promise { - this.logger.debug(`Refreshing all external libraries: force=${job.force}`); - - // Queue cleanup - await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); - - // Queue all library refresh - const libraries = await this.repository.getAll(true); - await this.jobRepository.queueAll( - libraries.map((library) => ({ - name: JobName.LIBRARY_SCAN, - data: { - id: library.id, - refreshModifiedFiles: !job.force, - refreshAllFiles: job.force ?? false, - }, - })), - ); - return JobStatus.SUCCESS; - } - - async handleOfflineRemoval(job: IEntityJob): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), - ); + let assetsFound = false; + this.logger.debug(`Will delete all assets in library ${libraryId}`); for await (const assets of assetPagination) { - this.logger.debug(`Removing ${assets.length} offline assets`); + if (assets.length > 0) { + assetsFound = true; + } + + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); await this.jobRepository.queueAll( assets.map((asset) => ({ name: JobName.ASSET_DELETION, @@ -558,116 +367,262 @@ export class LibraryService implements OnEvents { ); } + if (!assetsFound) { + this.logger.log(`Deleting library ${libraryId}`); + await this.libraryRepository.delete(libraryId); + } return JobStatus.SUCCESS; } - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { - const library = await this.repository.get(job.id); - if (!library) { - this.logger.warn('Library not found'); + async handleSyncFile(job: ILibraryFileJob): Promise { + // Only needs to handle new assets + const assetPath = path.normalize(job.assetPath); + + 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(`Refreshing library: ${job.id}`); + this.logger.log(`Importing new library asset: ${assetPath}`); - const crawledAssetPaths = await this.getPathTrie(library); - this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); - - const assetIdsToMarkOffline = []; - const assetIdsToMarkOnline = []; - const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) => - this.assetRepository.getExternalLibraryAssetPaths(pagination, library.id), - ); - - this.logger.verbose(`Crawled asset paths paginated`); - - const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; - for await (const page of pagination) { - for (const asset of page) { - const isOffline = !crawledAssetPaths.has(asset.originalPath); - if (isOffline && !asset.isOffline) { - assetIdsToMarkOffline.push(asset.id); - this.logger.verbose(`Added to mark-offline list: ${asset.originalPath}`); - } - - if (!isOffline && asset.isOffline) { - assetIdsToMarkOnline.push(asset.id); - this.logger.verbose(`Added to mark-online list: ${asset.originalPath}`); - } - - if (!shouldScanAll) { - crawledAssetPaths.delete(asset.originalPath); - } - } + 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; } - this.logger.verbose(`Crawled assets have been checked for online/offline status`); + // TODO: device asset id is deprecated, remove it + const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); - if (assetIdsToMarkOffline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); - await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + + // TODO: doesn't xmp replace the file extension? Will need investigation + let sidecarPath: string | null = null; + if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { + sidecarPath = `${assetPath}.xmp`; } - if (assetIdsToMarkOnline.length > 0) { - this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); - await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); - } + const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - if (crawledAssetPaths.size > 0) { - if (!shouldScanAll) { - this.logger.debug(`Will import ${crawledAssetPaths.size} new asset(s)`); - } + const mtime = stat.mtime; - let batch = []; - for (const assetPath of crawledAssetPaths) { - batch.push(assetPath); + asset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: mtime, + fileModifiedAt: mtime, + localDateTime: mtime, + type: assetType, + originalFileName: parse(assetPath).base, - if (batch.length >= LIBRARY_SCAN_BATCH_SIZE) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - batch = []; - } - } + sidecarPath, + isExternal: true, + }); - if (batch.length > 0) { - await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false); - } - } - - await this.repository.update({ id: job.id, refreshedAt: new Date() }); + await this.queuePostSyncJobs(asset); return JobStatus.SUCCESS; } - private async getPathTrie(library: LibraryEntity): Promise> { - const pathValidation = await Promise.all( - library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)), - ); + async queuePostSyncJobs(asset: AssetEntity) { + this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); - const validImportPaths = pathValidation - .map((validation) => { - if (!validation.isValid) { - this.logger.error(`Skipping invalid import path: ${validation.importPath}. Reason: ${validation.message}`); - } - return validation; - }) - .filter((validation) => validation.isValid) - .map((validation) => validation.importPath); + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + } - const generator = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - exclusionPatterns: library.exclusionPatterns, + async queueScan(id: string) { + await this.findOrFail(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 } }); + } - const trie = new Trie(); - for await (const filePath of generator) { - trie.add(filePath); + async handleQueueSyncAll(): Promise { + this.logger.debug(`Refreshing 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, + data: { + id: library.id, + }, + })), + ); + await this.jobRepository.queueAll( + libraries.map((library) => ({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, + data: { + id: library.id, + }, + })), + ); + return JobStatus.SUCCESS; + } + + async handleSyncAsset(job: ILibraryAssetJob): Promise { + const asset = await this.assetRepository.getById(job.id); + if (!asset) { + return JobStatus.SKIPPED; } - return trie; + 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 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; + } + + 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; + } + + 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 (isAssetModified) { + this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); + await this.queuePostSyncJobs(asset); + } + return JobStatus.SUCCESS; + } + + async handleQueueSyncFiles(job: IEntityJob): Promise { + const library = await this.libraryRepository.get(job.id); + if (!library) { + this.logger.debug(`Library ${job.id} not found, skipping refresh`); + return JobStatus.SKIPPED; + } + + this.logger.log(`Refreshing library ${library.id} for new assets`); + + const validImportPaths: string[] = []; + + for (const importPath of library.importPaths) { + const validation = await this.validateImportPath(importPath); + if (validation.isValid) { + validImportPaths.push(path.normalize(importPath)); + } else { + this.logger.warn(`Skipping invalid import path: ${importPath}. Reason: ${validation.message}`); + } + } + + if (validImportPaths.length === 0) { + this.logger.warn(`No valid import paths found for library ${library.id}`); + } + + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let count = 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}...`); + } + + 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}`); + } + + await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); + + return JobStatus.SUCCESS; + } + + async handleQueueSyncAssets(job: IEntityJob): Promise { + const library = await this.libraryRepository.get(job.id); + if (!library) { + return JobStatus.SKIPPED; + } + + this.logger.log(`Scanning library ${library.id} for removed assets`); + + const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => + this.assetRepository.getAll(pagination, { libraryId: job.id, withDeleted: true }), + ); + + 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}...`); + } + + if (assetCount) { + this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); + } + + return JobStatus.SUCCESS; } private async findOrFail(id: string) { - const library = await this.repository.get(id); + const library = await this.libraryRepository.get(id); if (!library) { throw new BadRequestException('Library not found'); } diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index f8b73260af..fde2ba7e0f 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,34 +1,23 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MapService } from 'src/services/map.service'; +import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { partnerStub } from 'test/fixtures/partner.stub'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MapService.name, () => { let sut: MapService; + let albumMock: Mocked; - let loggerMock: Mocked; - let partnerMock: Mocked; let mapMock: Mocked; - let systemMetadataMock: Mocked; + let partnerMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - mapMock = newMapRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); - - sut = new MapService(albumMock, loggerMock, partnerMock, mapMock, systemMetadataMock); + ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -50,5 +39,62 @@ describe(MapService.name, () => { expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); }); + + it('should include partner assets', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + }; + partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mapMock.getMapMarkers.mockResolvedValue([marker]); + + const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); + + expect(mapMock.getMapMarkers).toHaveBeenCalledWith( + [authStub.user1.user.id, partnerStub.adminToUser1.sharedById], + expect.arrayContaining([]), + { withPartners: true }, + ); + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual(marker); + }); + + it('should include assets from shared albums', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + 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]); + + const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual(marker); + }); + }); + + describe('reverseGeocode', () => { + it('should reverse geocode a location', async () => { + mapMock.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 }); + }); }); }); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index ffd84a3e02..860a782e79 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,28 +1,9 @@ -import { Inject } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; -export class MapService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(MapService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class MapService extends BaseService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; if (options.withPartners) { @@ -43,17 +24,6 @@ export class MapService { return this.mapRepository.getMapMarkers(userIds, albumIds, options); } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.configCore.getConfig({ withCache: false }); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.mapRepository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); - } - async reverseGeocode(dto: MapReverseGeocodeDto) { const { lat: latitude, lon: longitude } = dto; // eventually this should probably return an array of results diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 7bb201f78f..65166f4293 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,5 +1,11 @@ -import { Stats } from 'node:fs'; +import type { Stats } from 'node:fs'; +import { SystemConfig } from 'src/config'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; import { + AssetFileType, + AssetPathType, + AssetType, AudioCodec, Colorspace, ImageFormat, @@ -7,14 +13,11 @@ import { TranscodeHWAccel, TranscodePolicy, VideoCodec, -} from 'src/config'; -import { AssetType } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; +} 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 { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -24,51 +27,24 @@ 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 { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; 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 cryptoMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - jobMock = newJobRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new MediaService( - assetMock, - personMock, - jobMock, - mediaMock, - storageMock, - systemMock, - moveMock, - cryptoMock, - loggerMock, - ); + ({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } = + newTestService(MediaService)); }); it('should be defined', () => { @@ -93,7 +69,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -126,7 +102,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -151,7 +127,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -163,10 +139,10 @@ describe(MediaService.name, () => { hasNextPage: false, }); personMock.getAll.mockResolvedValue({ - items: [personStub.noThumbnail], + items: [personStub.noThumbnail, personStub.noThumbnail], hasNextPage: false, }); - personMock.getRandomFace.mockResolvedValue(faceStub.face1); + personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -175,6 +151,7 @@ describe(MediaService.name, () => { expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); expect(personMock.getRandomFace).toHaveBeenCalled(); + expect(personMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -201,7 +178,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -225,7 +202,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -249,7 +226,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -258,136 +235,233 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + 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.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] }); + + await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); + + expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, + ]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, + ]); + }); + }); + + describe('handleAssetMigration', () => { + 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(); + }); + + it('should move asset files', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + moveMock.create.mockResolvedValue({ + entityId: assetStub.image.id, + id: 'move-id', + newPath: '/new/path', + oldPath: '/old/path', + pathType: AssetPathType.ORIGINAL, + }); + + await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + expect(moveMock.create).toHaveBeenCalledTimes(2); + }); + }); + + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(assetMock.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); + + 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(); }); it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath }); - }); - it('should delete previous preview if different path', async () => { - const previousPreviewPath = assetStub.image.previewPath; + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + assetMock.getById.mockResolvedValue(assetStub.image); - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.unlink).toHaveBeenCalledWith(previousPreviewPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { - size: 1440, - format: ImageFormat.JPEG, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, + }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.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( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', '-frames:v 1', '-update 1', '-v verbose', - String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`, + String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=bt601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.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( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', '-frames:v 1', '-update 1', '-v verbose', - String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, + String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -395,267 +469,244 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', '-frames:v 1', '-update 1', '-v verbose', - String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, + String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); 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.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); + 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); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.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`; - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, - format, - quality: 80, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format, + size: 1440, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath }); - }, - ); + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); + + 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); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.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, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); it('should delete previous thumbnail if different path', async () => { - const previousThumbnailPath = assetStub.image.thumbnailPath; + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + assetMock.getById.mockResolvedValue(assetStub.image); - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.unlink).toHaveBeenCalledWith(previousThumbnailPath); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + 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); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + 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, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(assetMock.update).toHaveBeenCalledWith({ - id: 'asset-id', - thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); - }); - 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.getByIds.mockResolvedValue([assetStub.imageDng]); + 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); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.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.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.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); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + ); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); - - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); @@ -722,21 +773,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -754,6 +806,27 @@ describe(MediaService.name, () => { expect(mediaMock.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]); + + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined(); + expect(mediaMock.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({ + ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, + }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video')); + + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + expect(mediaMock.transcode).toHaveBeenCalledTimes(1); + }); + it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); @@ -762,11 +835,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -777,26 +850,41 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); - it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { + 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' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), + ); + }); + + 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' } }); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + }), ); }); @@ -807,11 +895,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -823,11 +911,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -839,11 +927,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -855,11 +943,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -871,11 +959,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -889,11 +977,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -911,11 +999,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -933,11 +1021,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -949,11 +1037,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -964,11 +1052,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1027,11 +1115,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1043,11 +1131,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1059,11 +1147,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1081,11 +1169,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1103,11 +1191,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1119,11 +1207,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1135,11 +1223,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1151,11 +1239,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1167,11 +1255,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1183,11 +1271,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1199,11 +1287,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1215,11 +1303,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1231,10 +1319,10 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ - '-c:v av1', + '-c:v libsvtav1', '-movflags faststart', '-fps_mode passthrough', '-map 0:0', @@ -1245,7 +1333,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1257,11 +1345,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1273,11 +1361,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1289,11 +1377,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1305,11 +1393,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1351,7 +1439,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1371,7 +1459,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1389,11 +1477,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1405,11 +1493,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1421,11 +1509,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1437,11 +1525,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1453,11 +1541,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1471,7 +1559,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', '-hwaccel_output_format cuda', @@ -1480,7 +1568,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1494,7 +1582,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1502,7 +1590,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1515,7 +1603,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1528,14 +1616,14 @@ describe(MediaService.name, () => { '-refs 5', '-g 256', '-v verbose', - '-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720', + '-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq', '-preset 7', '-global_quality:v 23', '-maxrate 10000k', '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1554,14 +1642,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1574,11 +1662,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1591,21 +1679,22 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); it('should fail for qsv if no hw devices', async () => { - storageMock.readdir.mockResolvedValue([]); + storageMock.readdir.mockRejectedValue(new Error('Could not read directory')); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.'); }); it('should use hardware decoding for qsv if enabled', async () => { @@ -1621,7 +1710,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1633,7 +1722,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1650,7 +1739,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1663,7 +1752,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1679,11 +1768,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1696,7 +1785,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1710,12 +1799,12 @@ describe(MediaService.name, () => { '-map 0:1', '-g 256', '-v verbose', - '-vf format=nv12,hwupload,scale_vaapi=-2:720', + '-vf format=nv12,hwupload,scale_vaapi=-2:720:mode=hq:out_range=pc', '-compression_level 7', '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1728,7 +1817,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1741,7 +1830,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1754,7 +1843,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1767,7 +1856,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1780,14 +1869,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1800,14 +1889,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1820,14 +1909,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1842,14 +1931,87 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), + ); + }); + + it('should use hardware decoding for vaapi if enabled', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, + }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.arrayContaining([ + '-hwaccel vaapi', + '-hwaccel_output_format vaapi', + '-noautorotate', + '-threads 1', + ]), + outputOptions: expect.arrayContaining([ + expect.stringContaining('scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12'), + ]), + twoPass: false, + }), + ); + }); + + it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, + }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']), + outputOptions: expect.arrayContaining([ + expect.stringContaining( + 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=vaapi:reverse=1,format=vaapi', + ), + ]), + twoPass: false, + }), + ); + }); + + it('should use preferred device for vaapi when hardware decoding', async () => { + storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, + }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_device /dev/dri/renderD129']), + outputOptions: expect.any(Array), + twoPass: false, + }), ); }); @@ -1864,11 +2026,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1883,7 +2045,7 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); @@ -1891,7 +2053,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', '-hwaccel_output_format drm_prime', @@ -1913,13 +2075,13 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); systemMock.get.mockResolvedValue({ ffmpeg: { @@ -1934,17 +2096,17 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -1954,17 +2116,17 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -1974,7 +2136,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1982,13 +2144,13 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, @@ -1998,21 +2160,21 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( - 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', ), ]), twoPass: false, - }, + }), ); }); it('should use software decoding and tone-mapping if opencl is not available', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); - storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => false, isCharacterDevice: () => false }); + storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, @@ -2022,77 +2184,125 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( - 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', ), ]), twoPass: false, + }), + ); + }); + + 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]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + 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]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + 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]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, }, ); }); - }); - 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]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + 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]); + await sut.handleVideoConversion({ id: assetStub.video.id }); - 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]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); - it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); + it('should process unknown audio stream', async () => { + mediaMock.probe.mockResolvedValue(probeStub.audioStreamUnknown); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:a copy']), + twoPass: false, + }), + ); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9d5b4ed858..8393f5dc76 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,77 +1,52 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; import { + AssetFileType, + AssetPathType, + AssetType, AudioCodec, Colorspace, - ImageFormat, + LogLevel, + StorageFolder, TranscodeHWAccel, TranscodePolicy, TranscodeTarget, VideoCodec, VideoContainer, -} from 'src/config'; -import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +} from 'src/enum'; +import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { IBaseJob, IEntityJob, - IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobItem, JobName, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.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 { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class MediaService { - private configCore: SystemConfigCore; - private storageCore: StorageCore; +export class MediaService extends BaseService { private maliOpenCL?: boolean; private devices?: string[]; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true }) + ? this.assetRepository.getAll(pagination, { + isVisible: true, + withDeleted: true, + withArchived: true, + }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); @@ -79,16 +54,12 @@ export class MediaService { const jobs: JobItem[] = []; for (const asset of assets) { - if (!asset.previewPath || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - if (!asset.thumbnailPath) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -150,138 +121,140 @@ export class MediaService { } async handleAssetMigration({ id }: IEntityJob): Promise { - const { image } = await this.configCore.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id]); + const { image } = await this.getConfig({ withCache: true }); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true }), - ]); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); - if (asset.previewPath && asset.previewPath !== previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) { + generated = await this.generateVideoThumbnails(asset); + } else if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); + return JobStatus.SKIPPED; + } + + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + } + + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(asset.previewPath); - } - await this.assetRepository.update({ id: asset.id, previewPath }); - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality: image.quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } - } - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`, - ); - return path; - } - - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true }), - ]); - if (!asset) { - return JobStatus.FAILED; + pathsToDelete.push(previewFile.path); } - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); - if (asset.thumbnailPath && asset.thumbnailPath !== thumbnailPath) { + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(asset.thumbnailPath); + pathsToDelete.push(thumbnailFile.path); } - await this.assetRepository.update({ id: asset.id, thumbnailPath }); + + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); + } + + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); + return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id]); - if (!asset) { - return JobStatus.FAILED; + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const mainVideoStream = this.getMainStream(videoStreams); + if (!mainVideoStream) { + throw new Error(`No video streams found for asset ${asset.id}`); } + const mainAudioStream = this.getMainStream(audioStreams); - if (!asset.previewPath) { - return JobStatus.FAILED; - } + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const thumbhash = await this.mediaRepository.generateThumbhash(asset.previewPath); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); - return JobStatus.SUCCESS; + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { @@ -312,7 +285,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -324,19 +299,21 @@ export class MediaService { return JobStatus.FAILED; } - const { ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -345,16 +322,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + return JobStatus.FAILED; } + this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); command = config.getCommand(target, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(input, output, command); @@ -368,18 +349,16 @@ export class MediaService { } private getMainStream(streams: T[]): T { - return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0]; + return streams + .filter((stream) => stream.codecName !== 'unknown') + .sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0]; } private getTranscodeTarget( config: SystemConfigFFmpegDto, - videoStream?: VideoStreamInfo, + videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo, ): TranscodeTarget { - if (!videoStream && !audioStream) { - return TranscodeTarget.NONE; - } - const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream); const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream); @@ -421,11 +400,7 @@ export class MediaService { } } - private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean { - if (!stream) { - return false; - } - + private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean { const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; @@ -521,7 +496,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index cee3113f00..b5dd4c2553 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,24 +1,22 @@ import { BadRequestException } from '@nestjs/common'; -import { MemoryType } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { MemoryService } from 'src/services/memory.service'; import { authStub } from 'test/fixtures/auth.stub'; import { memoryStub } from 'test/fixtures/memory.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MemoryService.name, () => { - let accessMock: IAccessRepositoryMock; - let memoryMock: Mocked; let sut: MemoryService; - beforeEach(() => { - accessMock = newAccessRepositoryMock(); - memoryMock = newMemoryRepositoryMock(); + let accessMock: IAccessRepositoryMock; + let memoryMock: Mocked; - sut = new MemoryService(accessMock, memoryMock); + beforeEach(() => { + ({ sut, accessMock, memoryMock } = newTestService(MemoryService)); }); it('should be defined', () => { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 0164dd0b96..816b0fddeb 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,31 +1,21 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { BadRequestException, Injectable } from '@nestjs/common'; 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 { AssetEntity } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() -export class MemoryService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) private accessRepository: IAccessRepository, - @Inject(IMemoryRepository) private repository: IMemoryRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - +export class MemoryService extends BaseService { async search(auth: AuthDto) { - const memories = await this.repository.search(auth.user.id); + const memories = await this.memoryRepository.search(auth.user.id); return memories.map((memory) => mapMemory(memory)); } async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -34,8 +24,12 @@ export class MemoryService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, assetIds); - const memory = await this.repository.create({ + const allowedAssetIds = await this.checkAccess({ + auth, + permission: Permission.ASSET_SHARE, + ids: assetIds, + }); + const memory = await this.memoryRepository.create({ ownerId: auth.user.id, type: dto.type, data: dto.data, @@ -49,9 +43,9 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const memory = await this.repository.update({ + const memory = await this.memoryRepository.update({ id, isSaved: dto.isSaved, memoryAt: dto.memoryAt, @@ -62,28 +56,28 @@ export class MemoryService { } async remove(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_DELETE, id); - await this.repository.delete(id); + await this.requireAccess({ auth, permission: Permission.MEMORY_DELETE, ids: [id] }); + await this.memoryRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_READ, id); + await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); const hasSuccess = results.find(({ success }) => success); if (hasSuccess) { - await this.repository.update({ id, updatedAt: new Date() }); + await this.memoryRepository.update({ id, updatedAt: new Date() }); } return results; } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id); + await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const repos = { accessRepository: this.accessRepository, repository: this.repository }; + const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await removeAssets(auth, repos, { parentId: id, assetIds: dto.ids, @@ -92,14 +86,14 @@ export class MemoryService { const hasSuccess = results.find(({ success }) => success); if (hasSuccess) { - await this.repository.update({ id, updatedAt: new Date() }); + await this.memoryRepository.update({ id, updatedAt: new Date() }); } return results; } private async findOrFail(id: string) { - const memory = await this.repository.get(id); + const memory = await this.memoryRepository.get(id); if (!memory) { throw new BadRequestException('Memory not found'); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 956b45e214..cc6eae6e3b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,101 +1,80 @@ -import { BinaryField } from 'exiftool-vendored'; +import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; -import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetType, 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 { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.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 { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.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 { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +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'; describe(MetadataService.name, () => { - let albumMock: Mocked; - let assetMock: Mocked; - let systemMock: Mocked; - let cryptoRepository: Mocked; - let jobMock: Mocked; - let mapMock: Mocked; - let metadataMock: Mocked; - let moveMock: Mocked; - let mediaMock: Mocked; - let personMock: Mocked; - let storageMock: Mocked; - let eventMock: Mocked; - let databaseMock: Mocked; - let userMock: Mocked; - let loggerMock: Mocked; let sut: MetadataService; - beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - cryptoRepository = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - mapMock = newMapRepositoryMock(); - metadataMock = newMetadataRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - eventMock = newEventRepositoryMock(); - storageMock = newStorageRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); + let albumMock: Mocked; + let assetMock: 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; - sut = new MetadataService( + const mockReadTags = (exifData?: Partial, sidecarData?: Partial) => { + metadataMock.readTags.mockReset(); + metadataMock.readTags.mockResolvedValueOnce(exifData ?? {}); + metadataMock.readTags.mockResolvedValueOnce(sidecarData ?? {}); + }; + + beforeEach(() => { + ({ + sut, albumMock, assetMock, + cryptoMock, eventMock, - cryptoRepository, - databaseMock, jobMock, mapMock, mediaMock, metadataMock, - moveMock, personMock, storageMock, systemMock, + tagMock, userMock, - loggerMock, - ); + } = newTestService(MetadataService)); + + mockReadTags(); + + delete process.env.TZ; }); afterEach(async () => { - await sut.onShutdownEvent(); + await sut.onShutdown(); }); it('should be defined', () => { @@ -104,7 +83,7 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1); @@ -114,7 +93,7 @@ describe(MetadataService.name, () => { it('should return if reverse geocoding is disabled', async () => { systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); - await sut.onBootstrapEvent('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(jobMock.pause).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled(); @@ -212,11 +191,33 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(eventMock.clientSend).toHaveBeenCalledWith( - ClientEvent.ASSET_HIDDEN, - assetStub.livePhotoMotionAsset.ownerId, - assetStub.livePhotoMotionAsset.id, + 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, + }, + ]); + assetMock.findLivePhotoMatch.mockResolvedValue(null); + + 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', + }); }); }); @@ -256,7 +257,7 @@ describe(MetadataService.name, () => { 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]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled(); }); @@ -265,16 +266,10 @@ describe(MetadataService.name, () => { 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]); - metadataMock.readTags.mockImplementation((path) => { - const map = { - [assetStub.sidecar.originalPath]: originalDate.toISOString(), - [assetStub.sidecar.sidecarPath as string]: sidecarDate.toISOString(), - }; - return Promise.resolve({ CreationDate: map[path] ?? new Date().toISOString() }); - }); + mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.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, @@ -284,12 +279,31 @@ describe(MetadataService.name, () => { }); }); - it('should handle lists of numbers', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ ISO: [160] as any }); + it('should account for the server being in a non-UTC timezone', async () => { + process.env.TZ = 'America/Los_Angeles'; + assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), + }), + ); + + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: new Date('2022-01-01T00:00:00.000Z'), + }), + ); + }); + + it('should handle lists of numbers', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ ISO: [160] }); + + 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({ id: assetStub.image.id, @@ -303,13 +317,13 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mapMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); @@ -323,23 +337,178 @@ describe(MetadataService.name, () => { it('should discard latitude and longitude on null island', async () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); + it('should extract tags from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ TagsList: ['Parent'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract hierarchy from TagsList', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ TagsList: ['Parent/Child'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should extract tags from Keywords as a string', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Keywords: 'Parent' }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + }); + + it('should extract tags from Keywords as a list', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Keywords: ['Parent'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.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]); + mockReadTags({ Keywords: ['Parent', 2024] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + 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 }); + }); + + it('should extract hierarchal tags from Keywords', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Keywords: 'Parent/Child' }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should ignore Keywords when TagsList is present', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should extract hierarchy from HierarchicalSubject', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + expect(tagMock.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]); + mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + 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 }); + }); + + it('should extract ignore / characters in a HierarchicalSubject tag', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ + userId: 'user-id', + value: 'Mom|Dad', + parent: undefined, + }); + }); + + it('should ignore HierarchicalSubject when TagsList is present', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should remove existing tags', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); + }); + it('should not apply motion photos if asset is video', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(assetMock.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( @@ -347,22 +516,32 @@ describe(MetadataService.name, () => { ); }); + it('should handle an invalid Directory Item', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ + MotionPhoto: 1, + ContainerDirectory: [{ Foo: 100 }], + }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + }); + it('should extract the correct video orientation', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - metadataMock.readTags.mockResolvedValue(null); + mockReadTags({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), ); }); it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhotoVideo: new BinaryField(0, ''), // The below two are included to ensure that the MotionPhotoVideo tag is extracted @@ -370,10 +549,10 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); metadataMock.extractBinaryTag.mockResolvedValue(video); @@ -382,7 +561,9 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(assetMock.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -399,7 +580,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -408,15 +589,15 @@ describe(MetadataService.name, () => { it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); metadataMock.extractBinaryTag.mockResolvedValue(video); @@ -425,7 +606,9 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(assetMock.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -442,7 +625,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -451,21 +634,23 @@ describe(MetadataService.name, () => { it('should extract the motion photo video from the XMP directory entry ', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(storageMock.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), @@ -486,7 +671,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -495,13 +680,13 @@ describe(MetadataService.name, () => { it('should delete old motion photo video assets if they do not match what is extracted', async () => { assetMock.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset })); const video = randomBytes(512); @@ -520,13 +705,13 @@ describe(MetadataService.name, () => { 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]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); @@ -534,7 +719,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.writeFile).toHaveBeenCalledTimes(0); + expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); @@ -542,13 +727,13 @@ describe(MetadataService.name, () => { 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 }]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); @@ -568,13 +753,13 @@ describe(MetadataService.name, () => { assetMock.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true }, ]); - metadataMock.readTags.mockResolvedValue({ + mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); @@ -585,6 +770,8 @@ describe(MetadataService.name, () => { }); it('should save all metadata', async () => { + const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const tags: ImmichTags = { BitsPerSample: 1, ComponentBitDepth: 1, @@ -592,7 +779,7 @@ describe(MetadataService.name, () => { BitDepth: 1, ColorBitDepth: 1, ColorSpace: '1', - DateTimeOriginal: new Date('1970-01-01').toISOString(), + DateTimeOriginal: ExifDateTime.fromISO(dateForTest.toISOString()), ExposureTime: '100ms', FocalLength: 20, ImageDescription: 'test description', @@ -601,23 +788,24 @@ describe(MetadataService.name, () => { MediaGroupUUID: 'livePhoto', Make: 'test-factory', Model: "'mockel'", - ModifyDate: new Date('1970-01-01').toISOString(), + ModifyDate: ExifDateTime.fromISO(dateForTest.toISOString()), Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: '+02:00', + tz: 'UTC-11:30', + Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue(tags); + mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, - dateTimeOriginal: new Date('1970-01-01'), + dateTimeOriginal: dateForTest, description: tags.ImageDescription, exifImageHeight: null, exifImageWidth: null, @@ -638,22 +826,58 @@ describe(MetadataService.name, () => { profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, + rating: tags.Rating, + country: null, + state: null, + city: null, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, - fileCreatedAt: new Date('1970-01-01'), - localDateTime: new Date('1970-01-01'), + fileCreatedAt: dateForTest, + localDateTime: dateForTest, }); }); - it('should handle duration', async () => { + it('should extract +00:00 timezone from raw value', async () => { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + + // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const someDate = '2024-09-01T00:00:00.000'; + expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); + expect(ExifDateTime.fromISO(someDate + '+00:00')?.zone).toBe('UTC'); // this is the issue, should be UTC+0 + expect(ExifDateTime.fromISO(someDate + '+04:00')?.zone).toBe('UTC+4'); + + const tags: ImmichTags = { + DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), + tz: undefined, + }; assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: 6.21 }); + mockReadTags(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC+0', + }), + ); + }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + it('should extract duration', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); + + 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.objectContaining({ @@ -663,57 +887,82 @@ describe(MetadataService.name, () => { ); }); - it('should handle duration in ISO time string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: '00:00:08.41' }); - + it('should only extract duration for videos', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:08.410', + duration: null, }), ); }); - it('should handle duration as an object without Scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } }); + it('should omit duration of zero', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 0, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:06.200', + duration: null, }), ); }); - it('should handle duration with scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.111_111_111_111_11e-5, Value: 558_720 } }); + it('should a handle duration of 1 week', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 604_800, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, - duration: '00:00:06.207', + id: assetStub.video.id, + duration: '168:00:00.000', }), ); }); - it('trims whitespace from description', async () => { + it('should ignore duration from exif data', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Description: '\t \v \f \n \r' }); + mockReadTags({}, { Duration: { Value: 123 } }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); + }); + + it('should trim whitespace from description', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.upsertExif).toHaveBeenCalledWith( @@ -722,7 +971,7 @@ describe(MetadataService.name, () => { }), ); - metadataMock.readTags.mockResolvedValue({ ImageDescription: ' my\n description' }); + mockReadTags({ ImageDescription: ' my\n description' }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ @@ -730,6 +979,163 @@ describe(MetadataService.name, () => { }), ); }); + + it('should handle a numeric description', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Description: 1000 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + description: '1000', + }), + ); + }); + + it('should skip importing metadata when the feature is disabled', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } }); + mockReadTags(metadataStub.withFace); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.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 } } }); + mockReadTags(metadataStub.empty); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + }); + + it('should skip importing faces without name', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mockReadTags(metadataStub.withFaceNoName); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).not.toHaveBeenCalled(); + expect(personMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should skip importing faces with empty name', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + mockReadTags(metadataStub.withFaceEmptyName); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).not.toHaveBeenCalled(); + expect(personMock.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 } } }); + mockReadTags(metadataStub.withFace); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([personStub.withName.id]); + personMock.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( + [ + { + id: 'random-uuid', + assetId: assetStub.primaryImage.id, + personId: 'random-uuid', + imageHeight: 100, + imageWidth: 100, + boundingBoxX1: 0, + boundingBoxX2: 10, + boundingBoxY1: 0, + boundingBoxY2: 10, + sourceType: SourceType.EXIF, + }, + ], + [], + ); + expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.withName.id }, + }, + ]); + }); + + it('should assign metadata face tags to existing persons', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.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); + 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( + [ + { + id: 'random-uuid', + assetId: assetStub.primaryImage.id, + personId: personStub.withName.id, + imageHeight: 100, + imageWidth: 100, + boundingBoxX1: 0, + boundingBoxX2: 10, + boundingBoxY1: 0, + boundingBoxY2: 10, + sourceType: SourceType.EXIF, + }, + ], + [], + ); + expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + }); + + it('should handle invalid modify date', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ ModifyDate: '00:00:00.000' }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + modifyDate: expect.any(Date), + }), + ); + }); + + it('should handle invalid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Rating: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + ); + }); + + it('should handle valid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags({ Rating: 5 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: 5, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index ee3b24fad5..a45bcd4252 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { Injectable } from '@nestjs/common'; +import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -7,34 +7,30 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { OnEvent } from 'src/decorators'; +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository, OnEvents } from 'src/interfaces/event.interface'; +import { PersonEntity } from 'src/entities/person.entity'; +import { AssetType, 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 { IBaseJob, IEntityJob, - IJobRepository, ISidecarWriteJob, - JOBS_ASSET_PAGINATION_SIZE, JobName, + JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.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 { IUserRepository } from 'src/interfaces/user.interface'; +import { ReverseGeocodeResult } from 'src/interfaces/map.interface'; +import { ImmichTags } from 'src/interfaces/metadata.interface'; +import { BaseService } from 'src/services/base.service'; +import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; +import { upsertTags } from 'src/utils/tag'; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ @@ -49,23 +45,16 @@ const EXIF_DATE_TAGS: Array = [ ]; export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', + Horizontal = 1, + MirrorHorizontal = 2, + Rotate180 = 3, + MirrorVertical = 4, + MirrorHorizontalRotate270CW = 5, + Rotate90CW = 6, + MirrorHorizontalRotate90CW = 7, + Rotate270CW = 8, } -type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { - dateTimeOriginal: Date; -}; - -const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null); -const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null); - const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -84,50 +73,36 @@ const validate = (value: T): NonNullable | null => { return value ?? null; }; -@Injectable() -export class MetadataService implements OnEvents { - private storageCore: StorageCore; - private configCore: SystemConfigCore; +const validateRange = (value: number | undefined, min: number, max: number): NonNullable | null => { + // reutilizes the validate function + const val = validate(value); - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IMetadataRepository) private repository: IMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); + // check if the value is within the range + if (val == null || val < min || val > max) { + return null; } - async onBootstrapEvent(app: 'api' | 'microservices') { - if (app !== 'microservices') { + return val; +}; + +@Injectable() +export class MetadataService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { + if (app !== ImmichWorker.MICROSERVICES) { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } - async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) { + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.metadataRepository.teardown(); + } + + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -149,10 +124,6 @@ export class MetadataService implements OnEvents { } } - async onShutdownEvent() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -168,6 +139,7 @@ export class MetadataService implements OnEvents { const match = await this.assetRepository.findLivePhotoMatch({ livePhotoCID: asset.exifInfo.livePhotoCID, ownerId: asset.ownerId, + libraryId: asset.libraryId, otherAssetId: asset.id, type: otherType, }); @@ -182,8 +154,7 @@ export class MetadataService implements OnEvents { await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.albumRepository.removeAsset(motionAsset.id); - // Notify clients to hide the linked live photo asset - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); + await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); return JobStatus.SUCCESS; } @@ -206,53 +177,73 @@ export class MetadataService implements OnEvents { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id]); + const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); + const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); if (!asset) { return JobStatus.FAILED; } - const { exifData, tags } = await this.exifData(asset); + const stats = await this.storageRepository.stat(asset.originalPath); - if (asset.type === AssetType.VIDEO) { - const { videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const exifTags = await this.getExifTags(asset); - if (videoStreams[0]) { - switch (videoStreams[0].rotation) { - case -90: { - exifData.orientation = Orientation.Rotate90CW; - break; - } - case 0: { - exifData.orientation = Orientation.Horizontal; - break; - } - case 90: { - exifData.orientation = Orientation.Rotate270CW; - break; - } - case 180: { - exifData.orientation = Orientation.Rotate180; - break; - } - } - } - } + 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 exifData: Partial = { + assetId: asset.id, + + // dates + dateTimeOriginal, + modifyDate, + timeZone, + + // gps + latitude, + longitude, + country, + state, + city, + + // image/file + fileSizeInByte: stats.size, + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + orientation: validate(exifTags.Orientation)?.toString() ?? null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + + // camera + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO) as number, + exposureTime: exifTags.ExposureTime ?? null, + lensModel: exifTags.LensModel ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + + // comments + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + profileDescription: exifTags.ProfileDescription || null, + rating: validateRange(exifTags.Rating, 0, 5), + + // grouping + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + }; + + await this.applyTagList(asset, exifTags); + await this.applyMotionPhotos(asset, exifTags); - await this.applyMotionPhotos(asset, tags); - await this.applyReverseGeocoding(asset, exifData); await this.assetRepository.upsertExif(exifData); - const dateTimeOriginal = exifData.dateTimeOriginal; - let localDateTime = dateTimeOriginal ?? undefined; - - const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0; - - if (dateTimeOriginal && timeZoneOffset) { - localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); - } await this.assetRepository.update({ id: asset.id, - duration: tags.Duration ? this.getDuration(tags.Duration) : null, + duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -262,6 +253,10 @@ export class MetadataService implements OnEvents { metadataExtractedAt: new Date(), }); + if (isFaceImportEnabled(metadata)) { + await this.applyTaggedFaces(asset, exifTags); + } + return JobStatus.SUCCESS; } @@ -293,21 +288,35 @@ export class MetadataService implements OnEvents { return this.processSidecar(id, false); } + @OnEvent({ name: 'asset.tag' }) + async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + + @OnEvent({ name: 'asset.untag' }) + async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { + await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + } + async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude } = job; - const [asset] = await this.assetRepository.getByIds([id]); + const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; + const [asset] = await this.assetRepository.getByIds([id], { tags: true }); if (!asset) { return JobStatus.FAILED; } + const tagsList = (asset.tags || []).map((tag) => tag.value); + const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; - const exif = _.omitBy( - { + const exif = _.omitBy( + { Description: description, ImageDescription: description, DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + Rating: rating, + TagsList: tags ? tagsList : undefined, }, _.isUndefined, ); @@ -316,7 +325,7 @@ export class MetadataService implements OnEvents { return JobStatus.SKIPPED; } - await this.repository.writeTags(sidecarPath, exif); + await this.metadataRepository.writeTags(sidecarPath, exif); if (!asset.sidecarPath) { await this.assetRepository.update({ id, sidecarPath }); @@ -325,25 +334,50 @@ export class MetadataService implements OnEvents { return JobStatus.SUCCESS; } - private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { latitude, longitude } = exifData; - const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); - if (!reverseGeocoding.enabled || !longitude || !latitude) { - return; + 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) : {}; + + // 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]; + } } - try { - const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude }); - if (!reverseGeocode) { - return; - } - Object.assign(exifData, reverseGeocode); - } catch (error: Error | any) { - this.logger.warn( - `Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`, - error?.stack, + // prefer duration from video tags + delete mediaTags.Duration; + delete sidecarTags.Duration; + + return { ...mediaTags, ...videoTags, ...sidecarTags }; + } + + private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { + const tags: string[] = []; + if (exifTags.TagsList) { + tags.push(...exifTags.TagsList.map(String)); + } else if (exifTags.HierarchicalSubject) { + tags.push( + ...exifTags.HierarchicalSubject.map((tag) => + String(tag) + // convert | to / + .replaceAll('/', '') + .replaceAll('|', '/') + .replaceAll('', '|'), + ), ); + } else if (exifTags.Keywords) { + let keywords = exifTags.Keywords; + if (!Array.isArray(keywords)) { + keywords = [keywords]; + } + tags.push(...keywords.map(String)); } + + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); + await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) }); } private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { @@ -365,7 +399,7 @@ export class MetadataService implements OnEvents { 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; @@ -390,11 +424,11 @@ export class MetadataService implements OnEvents { // Samsung MotionPhoto video extraction // HEIC-encoded if (hasMotionPhotoVideo) { - video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); + video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); } // JPEG-encoded; HEIC also contains these tags, so this conditional must come second else if (hasEmbeddedVideoFile) { - video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); + video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); } // Default video extraction else { @@ -467,7 +501,7 @@ export class MetadataService implements OnEvents { const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); if (!existsOnDisk) { this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); + await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } @@ -478,61 +512,130 @@ export class MetadataService implements OnEvents { } } - private async exifData( - asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> { - const stats = await this.storageRepository.stat(asset.originalPath); - const mediaTags = await this.repository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; + private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) { + if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { + return; + } - // ensure date from sidecar is used if present - const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); - if (mediaTags && hasDateOverride) { - for (const tag of EXIF_DATE_TAGS) { - delete mediaTags[tag]; + const facesToAdd: Partial[] = []; + const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); + const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); + const missing: Partial[] = []; + const missingWithFaceAsset: Partial[] = []; + for (const region of tags.RegionInfo.RegionList) { + if (!region.Name) { + continue; + } + + const imageWidth = tags.RegionInfo.AppliedToDimensions.W; + const imageHeight = tags.RegionInfo.AppliedToDimensions.H; + const loweredName = region.Name.toLowerCase(); + const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); + + const face = { + id: this.cryptoRepository.randomUUID(), + personId, + assetId: asset.id, + imageWidth, + imageHeight, + boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth), + boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight), + boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth), + boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight), + sourceType: SourceType.EXIF, + }; + + facesToAdd.push(face); + if (!existingNameMap.has(loweredName)) { + missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); + missingWithFaceAsset.push({ id: personId, faceAssetId: face.id }); } } - const tags = { ...mediaTags, ...sidecarTags }; - - this.logger.verbose('Exif Tags', tags); - - const exifData = { - // altitude: tags.GPSAltitude ?? null, - assetId: asset.id, - bitsPerSample: this.getBitsPerSample(tags), - colorspace: tags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, - description: (tags.ImageDescription || tags.Description || '').trim(), - exifImageHeight: validate(tags.ImageHeight), - exifImageWidth: validate(tags.ImageWidth), - exposureTime: tags.ExposureTime ?? null, - fileSizeInByte: stats.size, - fNumber: validate(tags.FNumber), - focalLength: validate(tags.FocalLength), - fps: validate(Number.parseFloat(tags.VideoFrameRate!)), - iso: validate(tags.ISO), - latitude: validate(tags.GPSLatitude), - lensModel: tags.LensModel ?? null, - livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(tags), - longitude: validate(tags.GPSLongitude), - make: tags.Make ?? null, - model: tags.Model ?? null, - modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(tags.Orientation)?.toString() ?? null, - profileDescription: tags.ProfileDescription || null, - projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null, - timeZone: tags.tz ?? null, - }; - - if (exifData.latitude === 0 && exifData.longitude === 0) { - this.logger.warn('Exif data has latitude and longitude of 0, setting to null'); - exifData.latitude = null; - exifData.longitude = null; + if (missing.length > 0) { + this.logger.debug(`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); } - return { exifData, tags }; + 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}`); + } + + if (facesToAdd.length > 0) { + this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`); + } + + if (facesToRemove.length > 0 || facesToAdd.length > 0) { + await this.personRepository.refreshFaces(facesToAdd, facesToRemove); + } + + if (missingWithFaceAsset.length > 0) { + await this.personRepository.updateAll(missingWithFaceAsset); + } + } + + 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}`); + + // timezone + let timeZone = exifTags.tz ?? null; + if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + timeZone = 'UTC+0'; + } + + if (timeZone) { + this.logger.verbose(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + } else { + this.logger.warn(`Asset ${asset.id} has no time zone information`); + } + + let dateTimeOriginal = dateTime?.toDate(); + let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); + if (!localDateTime || !dateTimeOriginal) { + this.logger.warn(`Asset ${asset.id} has no valid date, falling back to asset.fileCreatedAt`); + dateTimeOriginal = asset.fileCreatedAt; + localDateTime = asset.fileCreatedAt; + } + + 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 {} + + return { + dateTimeOriginal, + timeZone, + localDateTime, + modifyDate, + }; + } + + 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 getAutoStackId(tags: ImmichTags | null): string | null { @@ -542,13 +645,6 @@ export class MetadataService implements OnEvents { return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; } - private getDateTimeOriginal(tags: ImmichTags | Tags | null) { - if (!tags) { - return null; - } - return exifDate(firstDateTime(tags as Tags, EXIF_DATE_TAGS)); - } - private getBitsPerSample(tags: ImmichTags): number | null { const bitDepthTags = [ tags.BitsPerSample, @@ -567,16 +663,37 @@ export class MetadataService implements OnEvents { return bitsPerSample; } - private getDuration(seconds?: ImmichTags['Duration']): string { - let _seconds = seconds as number; + private async getVideoTags(originalPath: string) { + const { videoStreams, format } = await this.mediaRepository.probe(originalPath); - if (typeof seconds === 'object') { - _seconds = seconds.Value * (seconds?.Scale || 1); - } else if (typeof seconds === 'string') { - _seconds = Duration.fromISOTime(seconds).as('seconds'); + const tags: Pick = {}; + + if (videoStreams[0]) { + switch (videoStreams[0].rotation) { + case -90: { + tags.Orientation = Orientation.Rotate90CW; + break; + } + case 0: { + tags.Orientation = Orientation.Horizontal; + break; + } + case 90: { + tags.Orientation = Orientation.Rotate270CW; + break; + } + case 180: { + tags.Orientation = Orientation.Rotate180; + break; + } + } } - return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); + if (format.duration) { + tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + } + + return tags; } private async processSidecar(id: string, isSync: boolean): Promise { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index fe1f4edc07..c600077809 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,8 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { OnEvent } from 'src/decorators'; +import { ImmichWorker } from 'src/enum'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; +import { BackupService } from 'src/services/backup.service'; import { DuplicateService } from 'src/services/duplicate.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; @@ -14,15 +17,17 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; +import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; -import { otelShutdown } from 'src/utils/instrumentation'; @Injectable() -export class MicroservicesService implements OnEvents { +export class MicroservicesService { constructor( private auditService: AuditService, private assetService: AssetService, + private backupService: BackupService, private jobService: JobService, private libraryService: LibraryService, private mediaService: MediaService, @@ -33,19 +38,23 @@ export class MicroservicesService implements OnEvents { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, + private trashService: TrashService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, ) {} - async onBootstrapEvent(app: 'api' | 'microservices') { - if (app !== 'microservices') { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { + if (app !== ImmichWorker.MICROSERVICES) { return; } await this.jobService.init({ [JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data), [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(), + [JobName.BACKUP_DATABASE]: () => this.backupService.handleBackupDatabase(), [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data), [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(), [JobName.CLEAN_OLD_SESSION_TOKENS]: () => this.sessionService.handleCleanup(), @@ -62,9 +71,7 @@ export class MicroservicesService implements OnEvents { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), @@ -80,21 +87,20 @@ export class MicroservicesService implements OnEvents { [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), - [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), - [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(), + [JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk + [JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets + [JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), - [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), + [JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(), }); } - - async onShutdown() { - await otelShutdown(); - } } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 293cc11657..d07d06443a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,10 +1,13 @@ +import { plainToInstance } from 'class-transformer'; import { defaults, SystemConfig } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { UserMetadataKey } from 'src/entities/user-metadata.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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -12,13 +15,7 @@ import { NotificationService } from 'src/services/notification.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const configs = { @@ -59,38 +56,41 @@ const configs = { }; describe(NotificationService.name, () => { + let sut: NotificationService; + let albumMock: Mocked; let assetMock: Mocked; + let eventMock: Mocked; let jobMock: Mocked; - let loggerMock: Mocked; let notificationMock: Mocked; - let sut: NotificationService; let systemMock: Mocked; let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - notificationMock = newNotificationRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - - sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock); + ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = + newTestService(NotificationService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; const newConfig = configs.smtpEnabled; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -99,7 +99,7 @@ describe(NotificationService.name, () => { const newConfig = configs.smtpTransport; notificationMock.verifySmtp.mockResolvedValue(true); - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport); }); @@ -107,7 +107,15 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpEnabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); + expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); + }); + + it('skips smtp validation with DTO when there are no changes', async () => { + const oldConfig = { ...configs.smtpEnabled }; + const newConfig = plainToInstance(SystemConfigDto, configs.smtpEnabled); + + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); @@ -115,19 +123,44 @@ describe(NotificationService.name, () => { const oldConfig = { ...configs.smtpEnabled }; const newConfig = { ...configs.smtpDisabled }; - await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow(); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.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')); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); + }); + }); + + 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'); + }); + }); + + describe('onAssetShow', () => { + it('should queue the generate thumbnail job', async () => { + await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_THUMBNAILS, + data: { id: 'asset-id', notify: true }, + }); + }); }); describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { - await sut.onUserSignupEvent({ id: '', notify: false }); + await sut.onUserSignup({ id: '', notify: false }); expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should queue notify signup event if notify is true', async () => { - await sut.onUserSignupEvent({ id: '', notify: true }); + await sut.onUserSignup({ id: '', notify: true }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_SIGNUP, data: { id: '', tempPassword: undefined }, @@ -137,17 +170,17 @@ describe(NotificationService.name, () => { describe('onAlbumUpdateEvent', () => { it('should queue notify album update event', async () => { - await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' }); + await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_UPDATE, - data: { id: '', senderId: '42' }, + data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, }); }); }); describe('onAlbumInviteEvent', () => { it('should queue notify album invite event', async () => { - await sut.onAlbumInviteEvent({ id: '', userId: '42' }); + await sut.onAlbumInvite({ id: '', userId: '42' }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id: '', recipientId: '42' }, @@ -155,6 +188,74 @@ describe(NotificationService.name, () => { }); }); + describe('onSessionDeleteEvent', () => { + it('should send a on_session_delete client event', () => { + vi.useFakeTimers(); + sut.onSessionDelete({ sessionId: 'id' }); + expect(eventMock.clientSend).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + + expect(eventMock.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']); + }); + }); + + 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'); + }); + }); + + 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']); + }); + }); + + 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']); + }); + }); + + 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'); + }); + }); + + 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'); + }); + }); + + 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'); + }); + }); + + 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'); + }); + }); + describe('sendTestEmail', () => { it('should throw error if user could not be found', async () => { await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); @@ -333,7 +434,9 @@ describe(NotificationService.name, () => { notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -358,10 +461,15 @@ describe(NotificationService.name, () => { }); systemMock.get.mockResolvedValue({ server: {} }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - assetMock.getById.mockResolvedValue({ ...assetStub.image, thumbnailPath: 'path-to-thumb.jpg' }); + assetMock.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(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -389,7 +497,9 @@ describe(NotificationService.name, () => { assetMock.getById.mockResolvedValue(assetStub.image); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId); + expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, { + files: true, + }); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEND_EMAIL, data: expect.objectContaining({ @@ -402,34 +512,17 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { - await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); expect(userMock.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); expect(systemMock.get).not.toHaveBeenCalled(); }); - it('should filter out the sender', async () => { - albumMock.getById.mockResolvedValue({ - ...albumStub.emptyWithValidThumbnail, - albumUsers: [ - { user: { id: userStub.user1.id } } as AlbumUserEntity, - { user: { id: userStub.user2.id } } as AlbumUserEntity, - ], - }); - userMock.get.mockResolvedValue(userStub.user1); - notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - - await sut.handleAlbumUpdate({ id: '', senderId: userStub.user1.id }); - expect(userMock.get).not.toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); - expect(userMock.get).toHaveBeenCalledWith(userStub.user2.id, { withDeleted: false }); - expect(notificationMock.renderEmail).toHaveBeenCalledOnce(); - }); - it('should skip recipient that could not be looked up', async () => { albumMock.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, @@ -438,7 +531,7 @@ describe(NotificationService.name, () => { userMock.get.mockResolvedValueOnce(userStub.user1); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - await sut.handleAlbumUpdate({ id: '', senderId: '' }); + await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(notificationMock.renderEmail).not.toHaveBeenCalled(); }); @@ -461,7 +554,7 @@ describe(NotificationService.name, () => { }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - await sut.handleAlbumUpdate({ id: '', senderId: '' }); + await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(notificationMock.renderEmail).not.toHaveBeenCalled(); }); @@ -484,7 +577,7 @@ describe(NotificationService.name, () => { }); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - await sut.handleAlbumUpdate({ id: '', senderId: '' }); + await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(notificationMock.renderEmail).not.toHaveBeenCalled(); }); @@ -497,11 +590,24 @@ describe(NotificationService.name, () => { userMock.get.mockResolvedValue(userStub.user1); notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' }); - await sut.handleAlbumUpdate({ id: '', senderId: '' }); + 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(); }); + + 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); + await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { + id: '1', + delay: 300_000, + recipientIds: ['1', '2', '3', '4'], + }, + }); + }); }); describe('handleSendEmail', () => { @@ -510,11 +616,6 @@ describe(NotificationService.name, () => { await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); - it('should fail if email could not be sent', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); - }); - it('should send mail successfully', async () => { systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index c5f9a4f9f7..c3c7727468 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,56 +1,42 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { isEqual } from 'lodash'; -import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { - AlbumInviteEvent, - AlbumUpdateEvent, - OnEvents, - SystemConfigUpdateEvent, - UserSignupEvent, -} from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEmailJob, - IJobRepository, + IEntityJob, INotifyAlbumInviteJob, INotifyAlbumUpdateJob, INotifySignupJob, + JobItem, JobName, JobStatus, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; +import { getExternalDomain } from 'src/utils/misc'; +import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService implements OnEvents { - private configCore: SystemConfigCore; +export class NotificationService extends BaseService { + private static albumUpdateEmailDelayMs = 300_000; - constructor( - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(INotificationRepository) private notificationRepository: INotificationRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - ) { - this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast('on_config_update'); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); } - async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { + @OnEvent({ name: 'config.validate', priority: -100 }) + async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( newConfig.notifications.smtp.enabled && - !isEqual(oldConfig.notifications.smtp, newConfig.notifications.smtp) + !isEqualObject(oldConfig.notifications.smtp, newConfig.notifications.smtp) ) { await this.notificationRepository.verifySmtp(newConfig.notifications.smtp.transport); } @@ -60,20 +46,101 @@ export class NotificationService implements OnEvents { } } - async onUserSignupEvent({ notify, id, tempPassword }: UserSignupEvent) { + @OnEvent({ name: 'asset.hide' }) + onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { + this.eventRepository.clientSend('on_asset_hidden', userId, assetId); + } + + @OnEvent({ name: 'asset.show' }) + async onAssetShow({ assetId }: ArgOf<'asset.show'>) { + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); + } + + @OnEvent({ name: 'asset.trash' }) + onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { + this.eventRepository.clientSend('on_asset_trash', userId, [assetId]); + } + + @OnEvent({ name: 'asset.delete' }) + onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { + this.eventRepository.clientSend('on_asset_delete', userId, assetId); + } + + @OnEvent({ name: 'assets.trash' }) + onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { + this.eventRepository.clientSend('on_asset_trash', userId, assetIds); + } + + @OnEvent({ name: 'assets.restore' }) + onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { + this.eventRepository.clientSend('on_asset_restore', userId, assetIds); + } + + @OnEvent({ name: 'stack.create' }) + onStackCreate({ userId }: ArgOf<'stack.create'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stack.update' }) + onStackUpdate({ userId }: ArgOf<'stack.update'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stack.delete' }) + onStackDelete({ userId }: ArgOf<'stack.delete'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stacks.delete' }) + onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'user.signup' }) + async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - async onAlbumUpdateEvent({ id, updatedBy }: AlbumUpdateEvent) { - await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); + @OnEvent({ name: 'album.update' }) + async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) { + // if recipientIds is empty, album likely only has one user part of it, don't queue notification if so + if (recipientIds.length === 0) { + return; + } + + const job: JobItem = { + name: JobName.NOTIFY_ALBUM_UPDATE, + data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs }, + }; + + const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE); + if (previousJobData && this.isAlbumUpdateJob(previousJobData)) { + for (const id of previousJobData.recipientIds) { + if (!recipientIds.includes(id)) { + recipientIds.push(id); + } + } + } + await this.jobRepository.queue(job); } - async onAlbumInviteEvent({ id, userId }: AlbumInviteEvent) { + private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob { + return 'recipientIds' in job; + } + + @OnEvent({ name: 'album.invite' }) + async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } + @OnEvent({ name: 'session.delete' }) + onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { + // after the response is sent + setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); + } + async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { @@ -83,19 +150,20 @@ export class NotificationService implements OnEvents { try { await this.notificationRepository.verifySmtp(dto.transport); } catch (error) { - throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error }); + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } - const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { server } = await this.getConfig({ withCache: false }); + const { port } = this.configRepository.getEnv(); + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { - baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, + baseUrl: getExternalDomain(server, port), displayName: user.name, }, }); - await this.notificationRepository.sendEmail({ + const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', html, @@ -104,6 +172,8 @@ export class NotificationService implements OnEvents { replyTo: dto.replyTo || dto.from, smtp: dto.transport, }); + + return { messageId }; } async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @@ -112,11 +182,12 @@ export class NotificationService implements OnEvents { return JobStatus.SKIPPED; } - const { server } = await this.configCore.getConfig({ withCache: true }); - const { html, text } = this.notificationRepository.renderEmail({ + const { server } = await this.getConfig({ withCache: true }); + const { port } = this.configRepository.getEnv(); + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { - baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, + baseUrl: getExternalDomain(server, port), displayName: user.name, username: user.email, password: tempPassword, @@ -155,11 +226,12 @@ export class NotificationService implements OnEvents { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); - const { html, text } = this.notificationRepository.renderEmail({ + const { server } = await this.getConfig({ withCache: false }); + const { port } = this.configRepository.getEnv(); + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { - baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, + baseUrl: getExternalDomain(server, port), albumId: album.id, albumName: album.albumName, senderName: album.owner.name, @@ -182,7 +254,7 @@ export class NotificationService implements OnEvents { return JobStatus.SUCCESS; } - async handleAlbumUpdate({ id, senderId }: INotifyAlbumUpdateJob) { + async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) { const album = await this.albumRepository.getById(id, { withAssets: false }); if (!album) { @@ -194,10 +266,13 @@ export class NotificationService implements OnEvents { return JobStatus.SKIPPED; } - const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); + const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => + recipientIds.includes(user.id), + ); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); + const { port } = this.configRepository.getEnv(); for (const recipient of recipients) { const user = await this.userRepository.get(recipient.id, { withDeleted: false }); @@ -211,10 +286,10 @@ export class NotificationService implements OnEvents { continue; } - const { html, text } = this.notificationRepository.renderEmail({ + const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_UPDATE, data: { - baseUrl: server.externalDomain || DEFAULT_EXTERNAL_DOMAIN, + baseUrl: getExternalDomain(server, port), albumId: album.id, albumName: album.albumName, recipientName: recipient.name, @@ -238,7 +313,7 @@ export class NotificationService implements OnEvents { } async handleSendEmail(data: IEmailJob): Promise { - const { notifications } = await this.configCore.getConfig({ withCache: false }); + const { notifications } = await this.getConfig({ withCache: false }); if (!notifications.smtp.enabled) { return JobStatus.SKIPPED; } @@ -255,10 +330,6 @@ export class NotificationService implements OnEvents { imageAttachments: data.imageAttachments, }); - if (!response) { - return JobStatus.FAILED; - } - this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); return JobStatus.SUCCESS; @@ -269,14 +340,15 @@ export class NotificationService implements OnEvents { return; } - const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId); - if (!albumThumbnail?.thumbnailPath) { + const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true }); + const { thumbnailFile } = getAssetFiles(albumThumbnail?.files); + if (!thumbnailFile) { return; } return { - filename: `album-thumbnail${getFilenameExtension(albumThumbnail.thumbnailPath)}`, - path: albumThumbnail.thumbnailPath, + filename: `album-thumbnail${getFilenameExtension(thumbnailFile.path)}`, + path: thumbnailFile.path, cid: 'album-thumbnail', }; } diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index b2b3401251..2e11c4f9ad 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,20 +1,20 @@ import { BadRequestException } from '@nestjs/common'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(PartnerService.name, () => { let sut: PartnerService; + + let accessMock: IAccessRepositoryMock; let partnerMock: Mocked; - let accessMock: Mocked; beforeEach(() => { - partnerMock = newPartnerRepositoryMock(); - sut = new PartnerService(partnerMock, accessMock); + ({ sut, accessMock, partnerMock } = newTestService(PartnerService)); }); it('should work', () => { @@ -74,4 +74,24 @@ describe(PartnerService.name, () => { expect(partnerMock.remove).not.toHaveBeenCalled(); }); }); + + describe('update', () => { + it('should require access', async () => { + await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: false })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('should update partner', async () => { + accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); + partnerMock.update.mockResolvedValue(partnerStub.adminToUser1); + + await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); + expect(partnerMock.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 d26149dceb..ee36f1ce45 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,45 +1,37 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; import { PartnerEntity } from 'src/entities/partner.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { Permission } from 'src/enum'; +import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class PartnerService { - private access: AccessCore; - constructor( - @Inject(IPartnerRepository) private repository: IPartnerRepository, - @Inject(IAccessRepository) accessRepository: IAccessRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - +export class PartnerService extends BaseService { async create(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; - const exists = await this.repository.get(partnerId); + const exists = await this.partnerRepository.get(partnerId); if (exists) { throw new BadRequestException(`Partner already exists`); } - const partner = await this.repository.create(partnerId); + const partner = await this.partnerRepository.create(partnerId); return this.mapPartner(partner, PartnerDirection.SharedBy); } async remove(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; - const partner = await this.repository.get(partnerId); + const partner = await this.partnerRepository.get(partnerId); if (!partner) { throw new BadRequestException('Partner not found'); } - await this.repository.remove(partner); + await this.partnerRepository.remove(partner); } async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise { - const partners = await this.repository.getAll(auth.user.id); + const partners = await this.partnerRepository.getAll(auth.user.id); const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId'; return partners .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users @@ -48,10 +40,10 @@ export class PartnerService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await this.access.requirePermission(auth, Permission.PARTNER_UPDATE, sharedById); + await this.requireAccess({ auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; - const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); + const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline }); return this.mapPartner(entity, PartnerDirection.SharedWith); } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8a2e88b276..da4656be02 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,39 +1,26 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.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 { PersonService } from 'src/services/person.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +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, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { IsNull } from 'typeorm'; import { Mocked } from 'vitest'; @@ -48,65 +35,63 @@ const responseDto: PersonResponseDto = { const statistics = { assets: 3 }; +const faceId = 'face-id'; +const face = { + id: faceId, + assetId: 'asset-id', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, +}; +const faceSearch = { faceId, embedding: [1, 2, 3, 4] }; const detectFaceMock: DetectedFaces = { faces: [ { boundingBox: { - x1: 100, - y1: 100, - x2: 200, - y2: 200, + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, }, - embedding: [1, 2, 3, 4], + embedding: faceSearch.embedding, score: 0.2, }, ], - imageHeight: 500, - imageWidth: 400, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, }; describe(PersonService.name, () => { + let sut: PersonService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let systemMock: Mocked; + let cryptoMock: Mocked; let jobMock: Mocked; let machineLearningMock: Mocked; let mediaMock: Mocked; - let moveMock: Mocked; let personMock: Mocked; - let storageMock: Mocked; let searchMock: Mocked; - let cryptoMock: Mocked; - let loggerMock: Mocked; - let sut: PersonService; + let storageMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - jobMock = newJobRepositoryMock(); - machineLearningMock = newMachineLearningRepositoryMock(); - moveMock = newMoveRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - searchMock = newSearchRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new PersonService( + ({ + sut, accessMock, assetMock, + cryptoMock, + jobMock, machineLearningMock, - moveMock, mediaMock, personMock, - systemMock, - storageMock, - jobMock, searchMock, - cryptoMock, - loggerMock, - ); + storageMock, + systemMock, + } = newTestService(PersonService)); }); it('should be defined', () => { @@ -204,23 +189,6 @@ describe(PersonService.name, () => { }); }); - describe('getAssets', () => { - it('should require person.read permission', async () => { - personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(personMock.getAssets).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); - }); - - it("should return a person's assets", async () => { - personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await sut.getAssets(authStub.admin, 'person-1'); - expect(personMock.getAssets).toHaveBeenCalledWith('person-1'); - expect(accessMock.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); @@ -242,7 +210,6 @@ describe(PersonService.name, () => { it("should update a person's name", async () => { personMock.update.mockResolvedValue(personStub.withName); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); @@ -253,7 +220,6 @@ describe(PersonService.name, () => { it("should update a person's date of birth", async () => { personMock.update.mockResolvedValue(personStub.withBirthDate); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ @@ -272,7 +238,6 @@ describe(PersonService.name, () => { it('should update a person visibility', async () => { personMock.update.mockResolvedValue(personStub.withName); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); @@ -476,7 +441,7 @@ describe(PersonService.name, () => { hasNextPage: false, }); - await sut.handleQueueDetectFaces({}); + await sut.handleQueueDetectFaces({ force: false }); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); expect(jobMock.queueAll).toHaveBeenCalledWith([ @@ -492,13 +457,13 @@ describe(PersonService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.withName], - hasNextPage: false, - }); + personMock.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([ { @@ -508,9 +473,30 @@ describe(PersonService.name, () => { ]); }); + it('should refresh all assets', async () => { + assetMock.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([ + { + name: JobName.FACE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); + }); + it('should delete existing people and faces if forced', async () => { personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person], + items: [faceStub.face1.person, personStub.randomPerson], hasNextPage: false, }); personMock.getAllFaces.mockResolvedValue({ @@ -521,6 +507,7 @@ describe(PersonService.name, () => { items: [assetStub.image], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueDetectFaces({ force: true }); @@ -531,8 +518,8 @@ describe(PersonService.name, () => { data: { id: assetStub.image.id }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]); - expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath); + expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); @@ -561,10 +548,14 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } }); + expect(personMock.getAllFaces).toHaveBeenCalledWith( + { skip: 0, take: 1000 }, + { where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } }, + ); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -586,6 +577,7 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -616,6 +608,8 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); + await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); @@ -641,6 +635,7 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -651,10 +646,10 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + 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.mockResolvedValue({ - items: [faceStub.face1.person], + items: [faceStub.face1.person, personStub.randomPerson], hasNextPage: false, }); personMock.getAllFaces.mockResolvedValue({ @@ -662,21 +657,28 @@ describe(PersonService.name, () => { hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]); - expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath); + expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); describe('handleDetectFaces', () => { + beforeEach(() => { + cryptoMock.randomUUID.mockReturnValue(faceId); + }); + it('should skip if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -716,10 +718,9 @@ describe(PersonService.name, () => { await sut.handleDetectFaces({ id: assetStub.image.id }); expect(machineLearningMock.detectFaces).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(personMock.createFaces).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -731,29 +732,73 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { - personMock.createFaces.mockResolvedValue([faceStub.face1.id]); machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); - const faceId = 'face-id'; - cryptoMock.randomUUID.mockReturnValue(faceId); - const face = { - id: faceId, - assetId: 'asset-id', - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, - faceSearch: { faceId, embedding: [1, 2, 3, 4] }, - }; await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.createFaces).toHaveBeenCalledWith([face]); + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id } }, + { 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(); + }); + + 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] }]); + + 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(); + }); + + 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] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(jobMock.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(); + }); + + it('should add embedding to matching metadata face', async () => { + machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith( + [], + [], + [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], + ); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(personMock.reassignFace).not.toHaveBeenCalled(); + expect(personMock.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] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(jobMock.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(); @@ -768,7 +813,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { @@ -779,7 +823,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { @@ -789,7 +832,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should match existing person', async () => { @@ -946,16 +988,15 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true }); + 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( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -964,6 +1005,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', @@ -979,13 +1021,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -994,6 +1035,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1006,12 +1048,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1020,33 +1061,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.video.previewPath, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); @@ -1177,6 +1192,7 @@ describe(PersonService.name, () => { id: faceStub.face1.id, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, person: mapPerson(personStub.withName), }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 95c79573bd..e5f016d8ef 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,11 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ImageFormat } from 'src/config'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceResponseDto, @@ -23,18 +19,23 @@ import { mapPerson, } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { PersonPathType } from 'src/entities/move.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + AssetType, + CacheControl, + ImageFormat, + Permission, + PersonPathType, + SourceType, + SystemMetadataKey, +} from 'src/enum'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IBaseJob, IDeferrableJob, IEntityJob, - IJobRepository, INightlyJob, JOBS_ASSET_PAGINATION_SIZE, JobItem, @@ -42,54 +43,19 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { BoundingBox } from 'src/interfaces/machine-learning.interface'; +import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { UpdateFacesData } from 'src/interfaces/person.interface'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; -import { isFacialRecognitionEnabled } from 'src/utils/misc'; +import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @Injectable() -export class PersonService { - private access: AccessCore; - private configCore: SystemConfigCore; - private storageCore: StorageCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.access = AccessCore.create(accessRepository); - this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - repository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - +export class PersonService extends BaseService { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise { const { withHidden = false, page, size } = dto; const pagination = { @@ -97,12 +63,12 @@ export class PersonService { skip: (page - 1) * size, }; - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); - const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, { + const { machineLearning } = await this.getConfig({ withCache: false }); + const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden, }); - const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id); + const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); return { people: items.map((person) => mapPerson(person)), @@ -113,15 +79,15 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; for (const data of dto.data) { - const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id); + await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -129,7 +95,7 @@ export class PersonService { changeFeaturePhoto.push(face.person.id); } - await this.repository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, personId); } result.push(person); @@ -142,13 +108,12 @@ export class PersonService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); - - await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id); - const face = await this.repository.getFaceById(dto.id); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); + const face = await this.personRepository.getFaceById(dto.id); const person = await this.findOrFail(personId); - await this.repository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, personId); if (person.faceAssetId === null) { await this.createNewFeaturePhoto([person.id]); } @@ -160,8 +125,8 @@ export class PersonService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_READ, dto.id); - const faces = await this.repository.getFaces(dto.id); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] }); + const faces = await this.personRepository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -172,13 +137,10 @@ export class PersonService { const jobs: JobItem[] = []; for (const personId of changeFeaturePhoto) { - const assetFace = await this.repository.getRandomFace(personId); + const assetFace = await this.personRepository.getRandomFace(personId); if (assetFace !== null) { - await this.repository.update({ - id: personId, - faceAssetId: assetFace.id, - }); + await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); } } @@ -187,18 +149,18 @@ export class PersonService { } async getById(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); - return this.repository.getStatistics(id); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + return this.personRepository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); - const person = await this.repository.getById(id); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + const person = await this.personRepository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -210,14 +172,8 @@ export class PersonService { }); } - async getAssets(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.PERSON_READ, id); - const assets = await this.repository.getAssets(id); - return assets.map((asset) => mapAsset(asset)); - } - create(auth: AuthDto, dto: PersonCreateDto): Promise { - return this.repository.create({ + return this.personRepository.create({ ownerId: auth.user.id, name: dto.name, birthDate: dto.birthDate, @@ -226,14 +182,14 @@ export class PersonService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.access.requirePermission(auth, Permission.PERSON_WRITE, id); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await this.access.requirePermission(auth, Permission.ASSET_READ, assetId); - const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [assetId] }); + const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); } @@ -241,7 +197,7 @@ export class PersonService { faceId = face.id; } - const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); + const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -259,8 +215,8 @@ export class PersonService { name: person.name, birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, - }), - results.push({ id: person.id, success: true }); + }); + results.push({ id: person.id, success: true }); } catch (error: Error | any) { this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack); results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN }); @@ -271,46 +227,36 @@ export class PersonService { private async delete(people: PersonEntity[]) { await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.repository.delete(people); + await this.personRepository.delete(people); this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { - const people = await this.repository.getAllWithoutFaces(); + const people = await this.personRepository.getAllWithoutFaces(); await this.delete(people); return JobStatus.SUCCESS; } async handleQueueDetectFaces({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } if (force) { - await this.deleteAllPeople(); - await this.repository.deleteAllFaces(); + await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.handlePersonCleanup(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { + return force === false + ? this.assetRepository.getWithout(pagination, WithoutProperty.FACES) + : this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true, withArchived: true, isVisible: true, - }) - : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); + }); }); for await (const assets of assetPagination) { @@ -319,11 +265,15 @@ export class PersonService { ); } + if (force === undefined) { + await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); + } + return JobStatus.SUCCESS; } async handleDetectFaces({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -333,9 +283,11 @@ export class PersonService { faces: { person: false, }, + files: true, }; const [asset] = await this.assetRepository.getByIds([id], relations); - if (!asset || !asset.previewPath || asset.faces?.length > 0) { + const { previewFile } = getAssetFiles(asset.files); + if (!asset || !previewFile) { return JobStatus.FAILED; } @@ -343,50 +295,89 @@ export class PersonService { return JobStatus.SKIPPED; } - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.facialRecognition, ); + this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); - this.logger.debug(`${faces.length} faces detected in ${asset.previewPath}`); + const facesToAdd: (Partial & { id: string })[] = []; + const embeddings: FaceSearchEntity[] = []; + const mlFaceIds = new Set(); + for (const face of asset.faces) { + if (face.sourceType === SourceType.MACHINE_LEARNING) { + mlFaceIds.add(face.id); + } + } - if (faces.length > 0) { - await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); - const mappedFaces: Partial[] = []; - for (const face of faces) { + const heightScale = imageHeight / (asset.faces[0]?.imageHeight || 1); + const widthScale = imageWidth / (asset.faces[0]?.imageWidth || 1); + for (const { boundingBox, embedding } of faces) { + const scaledBox = { + x1: boundingBox.x1 * widthScale, + y1: boundingBox.y1 * heightScale, + x2: boundingBox.x2 * widthScale, + y2: boundingBox.y2 * heightScale, + }; + const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5); + + if (match && !mlFaceIds.delete(match.id)) { + embeddings.push({ faceId: match.id, embedding }); + } else if (!match) { const faceId = this.cryptoRepository.randomUUID(); - mappedFaces.push({ + facesToAdd.push({ id: faceId, assetId: asset.id, imageHeight, imageWidth, - boundingBoxX1: face.boundingBox.x1, - boundingBoxY1: face.boundingBox.y1, - boundingBoxX2: face.boundingBox.x2, - boundingBoxY2: face.boundingBox.y2, - faceSearch: { faceId, embedding: face.embedding }, + boundingBoxX1: boundingBox.x1, + boundingBoxY1: boundingBox.y1, + boundingBoxX2: boundingBox.x2, + boundingBoxY2: boundingBox.y2, }); + embeddings.push({ faceId, embedding }); } + } + const faceIdsToRemove = [...mlFaceIds]; - const faceIds = await this.repository.createFaces(mappedFaces); - await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } }))); + if (facesToAdd.length > 0 || faceIdsToRemove.length > 0 || embeddings.length > 0) { + await this.personRepository.refreshFaces(facesToAdd, faceIdsToRemove, embeddings); } - await this.assetRepository.upsertJobStatus({ - assetId: asset.id, - facesRecognizedAt: new Date(), - }); + if (faceIdsToRemove.length > 0) { + this.logger.log(`Removed ${faceIdsToRemove.length} faces below detection threshold in asset ${id}`); + } + + if (facesToAdd.length > 0) { + this.logger.log(`Detected ${facesToAdd.length} new faces in asset ${id}`); + const jobs = facesToAdd.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id } }) as const); + await this.jobRepository.queueAll([{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, ...jobs]); + } else if (embeddings.length > 0) { + this.logger.log(`Added ${embeddings.length} face embeddings for asset ${id}`); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, facesRecognizedAt: new Date() }); return JobStatus.SUCCESS; } + private iou(face: AssetFaceEntity, newBox: BoundingBox): number { + const x1 = Math.max(face.boundingBoxX1, newBox.x1); + const y1 = Math.max(face.boundingBoxY1, newBox.y1); + const x2 = Math.min(face.boundingBoxX2, newBox.x2); + const y2 = Math.min(face.boundingBoxY2, newBox.y2); + + const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1); + const area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1); + const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1); + const union = area1 + area2 - intersection; + + return intersection / union; + } + async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -396,7 +387,7 @@ export class PersonService { if (nightly) { const [state, latestFaceDate] = await Promise.all([ this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE), - this.repository.getLatestFaceDate(), + this.personRepository.getLatestFaceDate(), ]); if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) { @@ -408,7 +399,8 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.deleteAllPeople(); + await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, @@ -418,7 +410,9 @@ export class PersonService { const lastRun = new Date().toISOString(); const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }), + this.personRepository.getAllFaces(pagination, { + where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING }, + }), ); for await (const page of facePagination) { @@ -433,21 +427,26 @@ export class PersonService { } async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } - const face = await this.repository.getFaceByIdWithAssets( + const face = await this.personRepository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, - { id: true, personId: true, faceSearch: { embedding: true } }, + { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); return JobStatus.FAILED; } + if (face.sourceType !== SourceType.MACHINE_LEARNING) { + this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`); + return JobStatus.SKIPPED; + } + if (!face.faceSearch?.embedding) { this.logger.warn(`Face ${id} does not have an embedding`); return JobStatus.FAILED; @@ -458,7 +457,7 @@ export class PersonService { return JobStatus.SKIPPED; } - const matches = await this.smartInfoRepository.searchFaces({ + const matches = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, @@ -482,7 +481,7 @@ export class PersonService { let personId = matches.find((match) => match.face.personId)?.face.personId; if (!personId) { - const matchWithPerson = await this.smartInfoRepository.searchFaces({ + const matchWithPerson = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, @@ -497,21 +496,21 @@ export class PersonService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); - const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); + const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); personId = newPerson.id; } if (personId) { this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId }); + await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); } return JobStatus.SUCCESS; } async handlePersonMigration({ id }: IEntityJob): Promise { - const person = await this.repository.getById(id); + const person = await this.personRepository.getById(id); if (!person) { return JobStatus.FAILED; } @@ -522,18 +521,18 @@ export class PersonService { } async handleGeneratePersonThumbnail(data: IEntityJob): Promise { - const { machineLearning, image } = await this.configCore.getConfig({ withCache: true }); - if (!isFacialRecognitionEnabled(machineLearning)) { + const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); + if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } - const person = await this.repository.getById(data.id); + const person = await this.personRepository.getById(data.id); if (!person?.faceAssetId) { this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`); return JobStatus.FAILED; } - const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); + const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); if (face === null) { this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`); return JobStatus.FAILED; @@ -549,7 +548,10 @@ export class PersonService { imageHeight: oldHeight, } = face; - const asset = await this.assetRepository.getById(assetId, { exifInfo: true }); + const asset = await this.assetRepository.getById(assetId, { + exifInfo: true, + files: true, + }); if (!asset) { this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`); return JobStatus.FAILED; @@ -561,16 +563,16 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); - await this.repository.update({ id: person.id, thumbnailPath }); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); + await this.personRepository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; } @@ -581,13 +583,17 @@ export class PersonService { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.access.requirePermission(auth, Permission.PERSON_WRITE, id); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; - const allowedIds = await this.access.checkAccess(auth, Permission.PERSON_MERGE, mergeIds); + const allowedIds = await this.checkAccess({ + auth, + permission: Permission.PERSON_MERGE, + ids: mergeIds, + }); for (const mergeId of mergeIds) { const hasAccess = allowedIds.has(mergeId); @@ -597,7 +603,7 @@ export class PersonService { } try { - const mergePerson = await this.repository.getById(mergeId); + const mergePerson = await this.personRepository.getById(mergeId); if (!mergePerson) { results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); continue; @@ -613,14 +619,14 @@ export class PersonService { } if (Object.keys(update).length > 0) { - primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update }); + primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update }); } const mergeName = mergePerson.name || mergePerson.id; const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; this.logger.log(`Merging ${mergeName} into ${primaryName}`); - await this.repository.reassignFaces(mergeData); + await this.personRepository.reassignFaces(mergeData); await this.delete([mergePerson]); this.logger.log(`Merged ${mergeName} into ${primaryName}`); @@ -634,7 +640,7 @@ export class PersonService { } private async findOrFail(id: string) { - const person = await this.repository.getById(id); + const person = await this.personRepository.getById(id); if (!person) { throw new BadRequestException('Person not found'); } @@ -646,7 +652,8 @@ export class PersonService { throw new Error(`Asset ${asset.id} dimensions are unknown`); } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { throw new Error(`Asset ${asset.id} has no preview path`); } @@ -659,8 +666,8 @@ export class PersonService { return { width, height, inputPath: asset.originalPath }; } - const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath); - return { width, height, inputPath: asset.previewPath }; + const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path); + return { width, height, inputPath: previewFile.path }; } private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index afc98b69de..e0b03f31ae 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,59 +1,26 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; +import { SearchSuggestionType } from 'src/dtos/search.dto'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.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 { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; + let assetMock: Mocked; - let systemMock: Mocked; - let machineMock: Mocked; let personMock: Mocked; let searchMock: Mocked; - let partnerMock: Mocked; - let metadataMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - personMock = newPersonRepositoryMock(); - searchMock = newSearchRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - metadataMock = newMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new SearchService( - systemMock, - machineMock, - personMock, - searchMock, - assetMock, - partnerMock, - metadataMock, - loggerMock, - ); + ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); }); it('should work', () => { @@ -95,4 +62,22 @@ describe(SearchService.name, () => { expect(result).toEqual(expectedResponse); }); }); + + describe('getSearchSuggestions', () => { + it('should return search suggestions (including null)', async () => { + searchMock.getCountries.mockResolvedValue(['USA', null]); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), + ).resolves.toEqual(['USA', null]); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + }); + + it('should return search suggestions (without null)', async () => { + searchMock.getCountries.mockResolvedValue(['USA', null]); + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), + ).resolves.toEqual(['USA']); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + }); + }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 5010067a3f..03ffbe97db 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,11 +1,11 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +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 { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -14,37 +14,15 @@ import { SmartSearchDto, mapPlaces, } from 'src/dtos/search.dto'; -import { AssetOrder } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { AssetOrder } from 'src/enum'; +import { SearchExploreItem } from 'src/interfaces/search.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class SearchService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class SearchService extends BaseService { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); } @@ -92,18 +70,28 @@ export class SearchService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + } + + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { throw new BadRequestException('Smart search is not enabled'); } const userIds = await this.getUserIdsToSearch(auth); - const embedding = await this.machineLearning.encodeText(machineLearning.url, dto.query, machineLearning.clip); + const embedding = await this.machineLearningRepository.encodeText( + machineLearning.url, + dto.query, + machineLearning.clip, + ); const page = dto.page ?? 1; const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( @@ -111,7 +99,7 @@ export class SearchService { { ...dto, userIds, embedding }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } async getAssetsByCity(auth: AuthDto): Promise { @@ -120,22 +108,31 @@ export class SearchService { return assets.map((asset) => mapAsset(asset)); } - getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { + async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) { + const userIds = await this.getUserIdsToSearch(auth); + const results = await this.getSuggestions(userIds, dto); + return results.filter((result) => (dto.includeNull ? true : result !== null)); + } + + private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { switch (dto.type) { case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(auth.user.id); + return this.searchRepository.getCountries(userIds); } case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(auth.user.id, dto.country); + return this.searchRepository.getStates(userIds, dto.country); } case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); + return this.searchRepository.getCities(userIds, dto.country, dto.state); } case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); + return this.searchRepository.getCameraMakes(userIds, dto.model); } case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(auth.user.id, dto.make); + return this.searchRepository.getCameraModels(userIds, dto.make); + } + default: { + return []; } } } @@ -149,13 +146,13 @@ export class SearchService { return [auth.user.id, ...partnerIds]; } - private mapResponse(assets: AssetEntity[], nextPage: string | null): SearchResponseDto { + private mapResponse(assets: AssetEntity[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto { return { albums: { total: 0, count: 0, items: [], facets: [] }, assets: { total: assets.length, count: assets.length, - items: assets.map((asset) => mapAsset(asset)), + items: assets.map((asset) => mapAsset(asset, options)), facets: [], nextPage, }, diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 6c7ef03627..ab6eb3b1a4 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,37 +1,20 @@ -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +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 { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(ServerService.name, () => { let sut: ServerService; + let storageMock: Mocked; - let userMock: Mocked; - let serverInfoMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; - let cryptoMock: Mocked; + let userMock: Mocked; beforeEach(() => { - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - serverInfoMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - - sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock); + ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); }); it('should work', () => { @@ -160,6 +143,7 @@ describe(ServerService.name, () => { smartSearch: true, duplicateDetection: true, facialRecognition: true, + importFaces: false, map: true, reverseGeocoding: true, oauth: false, @@ -175,9 +159,9 @@ describe(ServerService.name, () => { }); }); - describe('getConfig', () => { + describe('getSystemConfig', () => { it('should respond the server configuration', async () => { - await expect(sut.getConfig()).resolves.toEqual({ + await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, @@ -185,6 +169,8 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); expect(systemMock.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 22196c4e26..3fc319a2fd 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,8 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { serverVersion } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -14,35 +13,17 @@ import { ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { OnEvents } from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; +import { UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService implements OnEvents { - private configCore: SystemConfigCore; - - constructor( - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - ) { - this.logger.setContext(ServerService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - async onBootstrapEvent(): Promise { +export class ServerService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { @@ -54,7 +35,7 @@ export class ServerService implements OnEvents { async getAboutInfo(): Promise { const version = `v${serverVersion.toString()}`; - const buildMetadata = getBuildMetadata(); + const { buildMetadata } = this.configRepository.getEnv(); const buildVersions = await this.serverInfoRepository.getBuildVersions(); const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); @@ -89,8 +70,9 @@ export class ServerService implements OnEvents { } async getFeatures(): Promise { - const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = - await this.configCore.getConfig({ withCache: false }); + const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } = + await this.getConfig({ withCache: false }); + const { configFile } = this.configRepository.getEnv(); return { smartSearch: isSmartSearchEnabled(machineLearning), @@ -98,24 +80,25 @@ export class ServerService implements OnEvents { duplicateDetection: isDuplicateDetectionEnabled(machineLearning), map: map.enabled, reverseGeocoding: reverseGeocoding.enabled, + importFaces: metadata.faces.import, sidecar: true, search: true, trash: trash.enabled, oauth: oauth.enabled, oauthAutoLaunch: oauth.autoLaunch, passwordLogin: passwordLogin.enabled, - configFile: this.configCore.isUsingConfigFile(), + configFile: !!configFile, email: notifications.smtp.enabled, }; } async getTheme() { - const { theme } = await this.configCore.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme; } - async getConfig(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); const isInitialized = await this.userRepository.hasAdmin(); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); @@ -127,6 +110,8 @@ export class ServerService implements OnEvents { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + mapDarkStyleUrl: config.map.darkStyle, + mapLightStyleUrl: config.map.lightStyle, }; } @@ -176,20 +161,13 @@ export class ServerService implements OnEvents { if (!dto.licenseKey.startsWith('IMSV-')) { throw new BadRequestException('Invalid license key'); } - const licenseValid = this.cryptoRepository.verifySha256( - dto.licenseKey, - dto.activationKey, - getServerLicensePublicKey(), - ); - + const { licensePublicKey } = this.configRepository.getEnv(); + const licenseValid = this.cryptoRepository.verifySha256(dto.licenseKey, dto.activationKey, licensePublicKey.server); if (!licenseValid) { throw new BadRequestException('Invalid license key'); } - const licenseData = { - ...dto, - activatedAt: new Date(), - }; + const licenseData = { ...dto, activatedAt: new Date() }; await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index ca3d2fd858..49d1227712 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,27 +1,21 @@ import { UserEntity } from 'src/entities/user.entity'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe('SessionService', () => { let sut: SessionService; + let accessMock: Mocked; - let loggerMock: Mocked; let sessionMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sessionMock = newSessionRepositoryMock(); - - sut = new SessionService(accessMock, loggerMock, sessionMock); + ({ sut, accessMock, sessionMock } = newTestService(SessionService)); }); it('should be defined', () => { diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index f72bf194c1..2e27942c66 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,26 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; -import { IAccessRepository } from 'src/interfaces/access.interface'; +import { Permission } from 'src/enum'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class SessionService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ISessionRepository) private sessionRepository: ISessionRepository, - ) { - this.logger.setContext(SessionService.name); - this.access = AccessCore.create(accessRepository); - } - +export class SessionService extends BaseService { async handleCleanup() { const sessions = await this.sessionRepository.search({ updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), @@ -46,7 +33,7 @@ export class SessionService { } async delete(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await this.requireAccess({ auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); } diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index f0b42b0153..6554421418 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -1,40 +1,25 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import _ from 'lodash'; -import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { SharedLinkType } from 'src/entities/shared-link.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { SharedLinkType } from 'src/enum'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.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, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; + let accessMock: IAccessRepositoryMock; - let cryptoMock: Mocked; - let shareMock: Mocked; - let systemMock: Mocked; - let logMock: Mocked; + let sharedLinkMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - logMock = newLoggerRepositoryMock(); - - sut = new SharedLinkService(accessMock, cryptoMock, logMock, shareMock, systemMock); + ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -43,55 +28,64 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); + sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sharedLinkMock.getAll).toHaveBeenCalledWith(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(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; - shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); - it('should throw an error for an password protected shared link', async () => { + it('should throw an error for an invalid password protected shared link', async () => { const authDto = authStub.adminSharedLink; - shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.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' }); + await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); + expect(sharedLinkMock.get).toHaveBeenCalledWith( + authStub.adminSharedLink.user.id, + authStub.adminSharedLink.sharedLink?.id, + ); }); }); describe('get', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); }); }); @@ -122,7 +116,7 @@ describe(SharedLinkService.name, () => { it('should create an album shared link', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - shareMock.create.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); @@ -130,7 +124,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, @@ -146,7 +140,7 @@ describe(SharedLinkService.name, () => { it('should create an individual shared link', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -160,7 +154,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -176,7 +170,7 @@ 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])); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -190,7 +184,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -207,18 +201,18 @@ describe(SharedLinkService.name, () => { describe('update', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); - shareMock.update.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(shareMock.update).toHaveBeenCalledWith({ + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, userId: authStub.user1.user.id, allowDownload: false, @@ -228,31 +222,31 @@ describe(SharedLinkService.name, () => { describe('remove', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await sut.remove(authStub.user1, sharedLinkStub.valid.id); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.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 () => { - shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( @@ -264,7 +258,7 @@ describe(SharedLinkService.name, () => { ]); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); - expect(shareMock.update).toHaveBeenCalledWith({ + expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [assetStub.image, { id: 'asset-3' }], }); @@ -273,15 +267,15 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.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 () => { - shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -290,29 +284,39 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(sharedLinkMock.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(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); - expect(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', - imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, + imageUrl: `http://localhost:2283/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); - expect(shareMock.get).toHaveBeenCalled(); + expect(sharedLinkMock.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: [] }); + 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(); }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 773e42ce8c..5ef140d26d 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,46 +1,25 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { + mapSharedLink, + mapSharedLinkWithoutMetadata, SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, - mapSharedLink, - mapSharedLinkWithoutMetadata, } from 'src/dtos/shared-link.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { OpenGraphTags } from 'src/utils/misc'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { Permission, SharedLinkType } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() -export class SharedLinkService { - private access: AccessCore; - private configCore: SystemConfigCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(SharedLinkService.name); - this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - getAll(auth: AuthDto): Promise { - return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); +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 getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { @@ -68,7 +47,7 @@ export class SharedLinkService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await this.access.requirePermission(auth, Permission.ALBUM_SHARE, dto.albumId); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -77,13 +56,13 @@ export class SharedLinkService { throw new BadRequestException('Invalid assetIds'); } - await this.access.requirePermission(auth, Permission.ASSET_SHARE, dto.assetIds); + await this.requireAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); break; } } - const sharedLink = await this.repository.create({ + const sharedLink = await this.sharedLinkRepository.create({ key: this.cryptoRepository.randomBytes(50), userId: auth.user.id, type: dto.type, @@ -102,7 +81,7 @@ export class SharedLinkService { async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { await this.findOrFail(auth.user.id, id); - const sharedLink = await this.repository.update({ + const sharedLink = await this.sharedLinkRepository.update({ id, userId: auth.user.id, description: dto.description, @@ -117,12 +96,12 @@ export class SharedLinkService { async remove(auth: AuthDto, id: string): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - await this.repository.remove(sharedLink); + await this.sharedLinkRepository.remove(sharedLink); } // TODO: replace `userId` with permissions and access control checks private async findOrFail(userId: string, id: string) { - const sharedLink = await this.repository.get(userId, id); + const sharedLink = await this.sharedLinkRepository.get(userId, id); if (!sharedLink) { throw new BadRequestException('Shared link not found'); } @@ -138,7 +117,11 @@ export class SharedLinkService { const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await this.checkAccess({ + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: AssetIdsResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -158,7 +141,7 @@ export class SharedLinkService { sharedLink.assets.push({ id: assetId } as AssetEntity); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } @@ -182,7 +165,7 @@ export class SharedLinkService { sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } @@ -192,7 +175,8 @@ export class SharedLinkService { return null; } - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); + const { port } = this.configRepository.getEnv(); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0; @@ -203,7 +187,7 @@ export class SharedLinkService { return { title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', description: sharedLink.description || `${assetCount} shared photos & videos`, - imageUrl: new URL(imagePath, config.server.externalDomain || DEFAULT_EXTERNAL_DOMAIN).href, + imageUrl: new URL(imagePath, getExternalDomain(config.server, port)).href, }; } diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 95f76edc49..f53822a9e2 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,7 +1,8 @@ +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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -9,34 +10,22 @@ import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; + let assetMock: Mocked; - let systemMock: Mocked; - let jobMock: Mocked; - let searchMock: Mocked; - let machineMock: Mocked; let databaseMock: Mocked; - let loggerMock: Mocked; + let jobMock: Mocked; + let machineLearningMock: Mocked; + let searchMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - jobMock = newJobRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock); + ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock } = + newTestService(SmartInfoService)); assetMock.getByIds.mockResolvedValue([assetStub.image]); }); @@ -45,6 +34,215 @@ describe(SmartInfoService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigValidateEvent', () => { + it('should allow a valid model', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(); + }); + + it('should allow including organization', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(); + }); + + it('should fail for an unsupported model', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).toThrow('Unknown CLIP model: test-model'); + }); + }); + + describe('onBootstrapEvent', () => { + it('should return if not microservices', async () => { + await sut.onBootstrap(ImmichWorker.API); + + 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(); + }); + + it('should return if machine learning is disabled', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); + + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(systemMock.get).toHaveBeenCalledTimes(1); + 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(); + }); + + it('should return if model and DB dimension size are equal', async () => { + searchMock.getDimensionSize.mockResolvedValue(512); + + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(systemMock.get).toHaveBeenCalledTimes(1); + 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(); + }); + + 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 }); + + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(systemMock.get).toHaveBeenCalledTimes(1); + 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); + }); + + it('should skip pausing and resuming queue if already paused', async () => { + searchMock.getDimensionSize.mockResolvedValue(768); + jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(systemMock.get).toHaveBeenCalledTimes(1); + 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(); + }); + }); + + describe('onConfigUpdateEvent', () => { + it('should return if machine learning is disabled', async () => { + systemMock.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(); + }); + + it('should return if model and DB dimension size are equal', async () => { + searchMock.getDimensionSize.mockResolvedValue(512); + + await sut.onConfigUpdate({ + newConfig: { + machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, + } as SystemConfig, + oldConfig: { + machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, + } 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(); + }); + + 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 }); + + await sut.onConfigUpdate({ + newConfig: { + machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true }, + } as SystemConfig, + oldConfig: { + machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, + } 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); + }); + + it('should clear embeddings if old and new models are different', async () => { + searchMock.getDimensionSize.mockResolvedValue(512); + jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await sut.onConfigUpdate({ + newConfig: { + machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, + } as SystemConfig, + oldConfig: { + machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, + } 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); + }); + + it('should skip pausing and resuming queue if already paused', async () => { + searchMock.getDimensionSize.mockResolvedValue(512); + jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); + + await sut.onConfigUpdate({ + newConfig: { + machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true }, + } as SystemConfig, + oldConfig: { + machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true }, + } 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(); + }); + }); + describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -89,7 +287,7 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { @@ -98,17 +296,17 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(searchMock.upsert).not.toHaveBeenCalled(); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(machineMock.encodeImage).toHaveBeenCalledWith( + expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - assetStub.image.previewPath, + '/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]); @@ -119,9 +317,33 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled(); }); + + it('should fail if asset could not be found', async () => { + assetMock.getByIds.mockResolvedValue([]); + + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); + + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(searchMock.upsert).not.toHaveBeenCalled(); + }); + + it('should wait for database', async () => { + machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + databaseMock.isBusy.mockReturnValue(true); + + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); + + expect(databaseMock.wait).toHaveBeenCalledWith(512); + expect(machineLearningMock.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]); + }); }); describe('getCLIPModelInfo', () => { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 72372470de..778f40c931 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,69 +1,99 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { Injectable } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; +import { OnEvent } 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 { IBaseJob, IEntityJob, - IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { isSmartSearchEnabled } from 'src/utils/misc'; +import { BaseService } from 'src/services/base.service'; +import { getAssetFiles } from 'src/utils/asset.util'; +import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService implements OnEvents { - private configCore: SystemConfigCore; +export class SmartInfoService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(app: ArgOf<'app.bootstrap'>) { + if (app !== ImmichWorker.MICROSERVICES) { + return; + } - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - @Inject(ISearchRepository) private repository: ISearchRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); + const config = await this.getConfig({ withCache: false }); + await this.init(config); } - async init() { - await this.jobRepository.pause(QueueName.SMART_SEARCH); - - await this.jobRepository.waitForQueueCompletion(QueueName.SMART_SEARCH); - - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); - - await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, () => - this.repository.init(machineLearning.clip.modelName), - ); - - await this.jobRepository.resume(QueueName.SMART_SEARCH); + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); } - async onConfigUpdateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) { - if (oldConfig.machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) { - await this.repository.init(newConfig.machineLearning.clip.modelName); + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { + try { + getCLIPModelInfo(newConfig.machineLearning.clip.modelName); + } catch { + throw new Error( + `Unknown CLIP model: ${newConfig.machineLearning.clip.modelName}. Please check the model name for typos and confirm this is a supported model.`, + ); } } + private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { + if (!isSmartSearchEnabled(newConfig.machineLearning)) { + return; + } + + await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => { + const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName); + const dbDimSize = await this.searchRepository.getDimensionSize(); + this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`); + + const modelChange = + oldConfig && oldConfig.machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName; + const dimSizeChange = dbDimSize !== dimSize; + if (!modelChange && !dimSizeChange) { + return; + } + + const { isPaused } = await this.jobRepository.getQueueStatus(QueueName.SMART_SEARCH); + if (!isPaused) { + await this.jobRepository.pause(QueueName.SMART_SEARCH); + } + await this.jobRepository.waitForQueueCompletion(QueueName.SMART_SEARCH); + + if (dimSizeChange) { + this.logger.log( + `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, + ); + this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); + await this.searchRepository.setDimensionSize(dimSize); + this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`); + } else { + await this.searchRepository.deleteAllSearchEmbeddings(); + } + + if (!isPaused) { + await this.jobRepository.resume(QueueName.SMART_SEARCH); + } + }); + } + async handleQueueEncodeClip({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } if (force) { - await this.repository.deleteAllSearchEmbeddings(); + await this.searchRepository.deleteAllSearchEmbeddings(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { @@ -82,12 +112,12 @@ export class SmartInfoService implements OnEvents { } async handleEncodeClip({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } @@ -96,13 +126,14 @@ export class SmartInfoService implements OnEvents { return JobStatus.SKIPPED; } - if (!asset.previewPath) { + const { previewFile } = getAssetFiles(asset.files); + if (!previewFile) { return JobStatus.FAILED; } - const embedding = await this.machineLearning.encodeImage( + const embedding = await this.machineLearningRepository.encodeImage( machineLearning.url, - asset.previewPath, + previewFile.path, machineLearning.clip, ); @@ -111,7 +142,7 @@ export class SmartInfoService implements OnEvents { await this.databaseRepository.wait(DatabaseLock.CLIPDimSize); } - await this.repository.upsert(asset.id, embedding); + await this.searchRepository.upsert(asset.id, embedding); return JobStatus.SUCCESS; } diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts new file mode 100644 index 0000000000..4e8813145c --- /dev/null +++ b/server/src/services/stack.service.spec.ts @@ -0,0 +1,193 @@ +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'; + +describe(StackService.name, () => { + let sut: StackService; + + let accessMock: IAccessRepositoryMock; + let eventMock: Mocked; + let stackMock: Mocked; + + beforeEach(() => { + ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService)); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('search', () => { + it('should search stacks', async () => { + stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + + await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); + expect(stackMock.search).toHaveBeenCalledWith({ + ownerId: authStub.admin.user.id, + primaryAssetId: assetStub.image.id, + }); + }); + }); + + describe('create', () => { + it('should require asset.update permissions', async () => { + await expect( + sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.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])); + await expect( + sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), + ).resolves.toEqual({ + id: 'stack-id', + primaryAssetId: assetStub.image.id, + assets: [ + expect.objectContaining({ id: assetStub.image.id }), + expect.objectContaining({ id: assetStub.image1.id }), + ], + }); + + expect(eventMock.emit).toHaveBeenCalledWith('stack.create', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + 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(); + }); + + it('should fail if stack could not be found', async () => { + accessMock.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'); + }); + + it('should get stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + + await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ + id: 'stack-id', + primaryAssetId: assetStub.image.id, + assets: [ + expect.objectContaining({ id: assetStub.image.id }), + expect.objectContaining({ id: assetStub.image1.id }), + ], + }); + expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + }); + }); + + describe('update', () => { + 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(); + }); + + it('should fail if stack could not be found', async () => { + accessMock.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(); + }); + + 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])); + + 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(); + }); + + 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])); + + await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); + + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(stackMock.update).toHaveBeenCalledWith({ id: 'stack-id', primaryAssetId: assetStub.image1.id }); + expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + }); + }); + + describe('delete', () => { + 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(); + }); + + it('should delete stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await sut.delete(authStub.admin, 'stack-id'); + + expect(stackMock.delete).toHaveBeenCalledWith('stack-id'); + expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + }); + }); + + describe('deleteAll', () => { + 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(); + }); + + it('should delete all stacks', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); + + expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']); + expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', { + stackIds: ['stack-id'], + userId: authStub.admin.user.id, + }); + }); + }); +}); diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts new file mode 100644 index 0000000000..58fccc8be2 --- /dev/null +++ b/server/src/services/stack.service.ts @@ -0,0 +1,69 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; +import { Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class StackService extends BaseService { + async search(auth: AuthDto, dto: StackSearchDto): Promise { + const stacks = await this.stackRepository.search({ + ownerId: auth.user.id, + primaryAssetId: dto.primaryAssetId, + }); + + return stacks.map((stack) => mapStack(stack, { auth })); + } + + async create(auth: AuthDto, dto: StackCreateDto): Promise { + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + + const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); + + await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id }); + + return mapStack(stack, { auth }); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.STACK_READ, ids: [id] }); + const stack = await this.findOrFail(id); + return mapStack(stack, { auth }); + } + + async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.STACK_UPDATE, ids: [id] }); + const stack = await this.findOrFail(id); + if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { + throw new BadRequestException('Primary asset must be in the stack'); + } + + const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); + + await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id }); + + return mapStack(updatedStack, { auth }); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: [id] }); + await this.stackRepository.delete(id); + await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id }); + } + + async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { + await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: dto.ids }); + await this.stackRepository.deleteAll(dto.ids); + await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); + } + + private async findOrFail(id: string) { + const stack = await this.stackRepository.getById(id); + if (!stack) { + throw new Error('Asset stack not found'); + } + + return stack; + } +} diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 7a9b9952e0..fd063bd50d 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,89 +1,54 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.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 { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { IUserRepository } from 'src/interfaces/user.interface'; 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 { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; + let albumMock: Mocked; let assetMock: Mocked; let cryptoMock: Mocked; - let databaseMock: Mocked; let moveMock: Mocked; - let personMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; let userMock: Mocked; - let loggerMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - assetMock = newAssetRepositoryMock(); - albumMock = newAlbumRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); + ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = + newTestService(StorageTemplateService)); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); - sut = new StorageTemplateService( - albumMock, - assetMock, - systemMock, - moveMock, - personMock, - storageMock, - userMock, - cryptoMock, - databaseMock, - loggerMock, - ); - - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); - describe('onConfigValidateEvent', () => { + describe('onConfigValidate', () => { it('should allow valid templates', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: - '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{album}}', + '{{y}}{{M}}{{W}}{{d}}{{h}}{{m}}{{s}}{{filename}}{{ext}}{{filetype}}{{filetypefull}}{{assetId}}{{#if album}}{{album}}{{else}}other{{/if}}', }, } as SystemConfig, oldConfig: {} as SystemConfig, @@ -93,7 +58,7 @@ describe(StorageTemplateService.name, () => { it('should fail for an invalid template', () => { expect(() => - sut.onConfigValidateEvent({ + sut.onConfigValidate({ newConfig: { storageTemplate: { template: '{{foo}}', @@ -105,6 +70,41 @@ describe(StorageTemplateService.name, () => { }); }); + describe('getStorageTemplateOptions', () => { + it('should send back the datetime variables', () => { + expect(sut.getStorageTemplateOptions()).toEqual({ + dayOptions: ['d', 'dd'], + hourOptions: ['h', 'hh', 'H', 'HH'], + minuteOptions: ['m', 'mm'], + monthOptions: ['M', 'MM', 'MMM', 'MMMM'], + presetOptions: [ + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}/{{filename}}', + '{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}', + '{{y}}/{{MMM}}/{{filename}}', + '{{y}}/{{MMMM}}/{{filename}}', + '{{y}}/{{MM}}/{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}/{{dd}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}/{{filename}}', + '{{y}}/{{y}}-{{WW}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', + '{{y}}/{{y}}-{{MM}}/{{assetId}}', + '{{y}}/{{y}}-{{WW}}/{{assetId}}', + '{{album}}/{{filename}}', + ], + secondOptions: ['s', 'ss', 'SSS'], + weekOptions: ['W', 'WW'], + yearOptions: ['y', 'yy'], + }); + }); + }); + describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); @@ -163,6 +163,51 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); + + it('should use handlebar if condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const album = albumStub.oneAsset; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.mockResolvedValueOnce([asset]); + albumMock.getByAssetId.mockResolvedValueOnce([album]); + + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + + expect(moveMock.create).toHaveBeenCalledWith({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); + + it('should use handlebar else condition for album', async () => { + const asset = assetStub.image; + const user = userStub.user1; + const config = structuredClone(defaults); + config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); + + userMock.get.mockResolvedValue(user); + assetMock.getByIds.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({ + entityId: asset.id, + newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, + oldPath: asset.originalPath, + pathType: AssetPathType.ORIGINAL, + }); + }); + 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`; @@ -200,6 +245,7 @@ describe(StorageTemplateService.name, () => { 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`; @@ -267,7 +313,7 @@ describe(StorageTemplateService.name, () => { entityId: assetStub.image.id, pathType: AssetPathType.ORIGINAL, oldPath: assetStub.image.originalPath, - newPath: newPath, + newPath, }); expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index e067252553..d239435660 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -1,37 +1,52 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; -import { - supportedDayTokens, - supportedHourTokens, - supportedMinuteTokens, - supportedMonthTokens, - supportedSecondTokens, - supportedWeekTokens, - supportedYearTokens, -} from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } 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 { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.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 { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; +const storageTokens = { + secondOptions: ['s', 'ss', 'SSS'], + minuteOptions: ['m', 'mm'], + dayOptions: ['d', 'dd'], + weekOptions: ['W', 'WW'], + hourOptions: ['h', 'hh', 'H', 'HH'], + yearOptions: ['y', 'yy'], + monthOptions: ['M', 'MM', 'MMM', 'MMMM'], +}; + +const storagePresets = [ + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}/{{filename}}', + '{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}', + '{{y}}/{{MMM}}/{{filename}}', + '{{y}}/{{MMMM}}/{{filename}}', + '{{y}}/{{MM}}/{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}/{{dd}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}/{{filename}}', + '{{y}}/{{y}}-{{WW}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', + '{{y}}/{{y}}-{{MM}}/{{assetId}}', + '{{y}}/{{y}}-{{WW}}/{{assetId}}', + '{{album}}/{{filename}}', +]; + export interface MoveAssetMetadata { storageLabel: string | null; filename: string; @@ -45,9 +60,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService implements OnEvents { - private configCore: SystemConfigCore; - private storageCore: StorageCore; +export class StorageTemplateService extends BaseService { private _template: { compiled: HandlebarsTemplateDelegate; raw: string; @@ -61,33 +74,17 @@ export class StorageTemplateService implements OnEvents { return this._template; } - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } } - onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) { + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); this.render(compiled, { @@ -107,8 +104,12 @@ export class StorageTemplateService implements OnEvents { } } + getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + return { ...storageTokens, presetOptions: storagePresets }; + } + async handleMigrationSingle({ id }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { return JobStatus.SKIPPED; @@ -138,7 +139,7 @@ export class StorageTemplateService implements OnEvents { async handleMigration(): Promise { this.logger.log('Starting storage template migration'); - const { storageTemplate } = await this.configCore.getConfig({ withCache: true }); + const { storageTemplate } = await this.getConfig({ withCache: true }); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); @@ -224,7 +225,7 @@ export class StorageTemplateService implements OnEvents { const storagePath = this.render(this.template.compiled, { asset, filename: sanitized, - extension: extension, + extension, albumName, }); const fullPath = path.normalize(path.join(rootPath, storagePath)); @@ -280,14 +281,6 @@ export class StorageTemplateService implements OnEvents { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, @@ -305,27 +298,17 @@ export class StorageTemplateService implements OnEvents { filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, //just throw into the root if it doesn't belong to an album - album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '.', + album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const zone = asset.exifInfo?.timeZone || systemTimeZone; const dt = DateTime.fromJSDate(asset.fileCreatedAt, { zone }); - const dateTokens = [ - ...supportedYearTokens, - ...supportedMonthTokens, - ...supportedWeekTokens, - ...supportedDayTokens, - ...supportedHourTokens, - ...supportedMinuteTokens, - ...supportedSecondTokens, - ]; - - for (const token of dateTokens) { + for (const token of Object.values(storageTokens).flat()) { substitutions[token] = dt.toFormat(token); } - return template(substitutions); + return template(substitutions).replaceAll(/\/{2,}/gm, '/'); } } diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 5ce6d92d26..dd9bb9969d 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,29 +1,141 @@ +import { SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { StorageService } from 'src/services/storage.service'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichStartupError, StorageService } from 'src/services/storage.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(StorageService.name, () => { let sut: StorageService; - let storageMock: Mocked; + + let configMock: Mocked; let loggerMock: Mocked; + let storageMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - storageMock = newStorageRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new StorageService(storageMock, loggerMock); + ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); }); it('should work', () => { expect(sut).toBeDefined(); }); - describe('onBootstrapEvent', () => { - it('should create the library folder on initialization', () => { - sut.onBootstrapEvent(); + describe('onBootstrap', () => { + it('should enable mount folder checking', async () => { + systemMock.get.mockResolvedValue(null); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + mountChecks: { + backups: true, + 'encoded-video': true, + library: true, + profile: true, + thumbs: true, + 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)); + }); + + it('should enable mount folder checking for a new folder type', async () => { + systemMock.get.mockResolvedValue({ + mountChecks: { + backups: false, + 'encoded-video': true, + library: false, + profile: true, + thumbs: true, + upload: true, + }, + }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + mountChecks: { + backups: true, + 'encoded-video': true, + library: true, + profile: true, + thumbs: true, + 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)); + }); + + 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'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); + + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); + expect(systemMock.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'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); + + expect(systemMock.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); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(loggerMock.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')); + + await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); + expect(systemMock.set).not.toHaveBeenCalled(); + }); + + it('should startup if checks are disabled', async () => { + systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); + configMock.getEnv.mockReturnValue( + mockEnvData({ + storage: { ignoreMountCheckErrors: true }, + }), + ); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 8222d7c46d..3b6a16fb41 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,22 +1,69 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { OnEvents } from 'src/interfaces/event.interface'; +import { Injectable } from '@nestjs/common'; +import { join } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } 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 { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { BaseService } from 'src/services/base.service'; + +export class ImmichStartupError extends Error {} +export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; + +const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; @Injectable() -export class StorageService implements OnEvents { - constructor( - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(StorageService.name); - } +export class StorageService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap() { + const envData = this.configRepository.getEnv(); - onBootstrapEvent() { - const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); - this.storageRepository.mkdirSync(libraryBase); + await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { + const flags = + (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || + ({ mountChecks: {} } as SystemFlags); + + if (!flags.mountChecks) { + flags.mountChecks = {}; + } + + let updated = false; + + this.logger.log(`Verifying system mount folder checks, current state: ${JSON.stringify(flags)}`); + + try { + // check each folder exists and is writable + for (const folder of Object.values(StorageFolder)) { + if (!flags.mountChecks[folder]) { + this.logger.log(`Writing initial mount file for the ${folder} folder`); + await this.createMountFile(folder); + } + + await this.verifyReadAccess(folder); + await this.verifyWriteAccess(folder); + + if (!flags.mountChecks[folder]) { + flags.mountChecks[folder] = true; + updated = true; + } + } + + if (updated) { + await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + this.logger.log('Successfully enabled system mount folders checks'); + } + + this.logger.log('Successfully verified system mount folder checks'); + } catch (error) { + if (envData.storage.ignoreMountCheckErrors) { + this.logger.error(error); + this.logger.warn('Ignoring mount folder errors'); + } else { + throw error; + } + } + }); } async handleDeleteFiles(job: IDeleteFilesJob) { @@ -37,4 +84,47 @@ export class StorageService implements OnEvents { return JobStatus.SUCCESS; } + + private async verifyReadAccess(folder: StorageFolder) { + const { internalPath, externalPath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.readFile(internalPath); + } catch (error) { + this.logger.error(`Failed to read ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to read "${externalPath} - ${docsMessage}"`); + } + } + + private async createMountFile(folder: StorageFolder) { + const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder); + try { + this.storageRepository.mkdirSync(folderPath); + await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + this.logger.warn('Found existing mount file, skipping creation'); + return; + } + this.logger.error(`Failed to create ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to create "${externalPath} - ${docsMessage}"`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { internalPath, externalPath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to write ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`); + } + } + + private getMountFilePaths(folder: StorageFolder) { + const folderPath = StorageCore.getBaseFolder(folder); + const internalPath = join(folderPath, '.immich'); + const externalPath = `/${folder}/.immich`; + + return { folderPath, internalPath, externalPath }; + } } diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index a0ded6dba3..8dc270d020 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,5 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -8,10 +7,7 @@ import { SyncService } from 'src/services/sync.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const untilDate = new Date(2024); @@ -19,17 +15,13 @@ const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: tr describe(SyncService.name, () => { let sut: SyncService; - let accessMock: Mocked; + let assetMock: Mocked; - let partnerMock: Mocked; let auditMock: Mocked; + let partnerMock: Mocked; beforeEach(() => { - partnerMock = newPartnerRepositoryMock(); - assetMock = newAssetRepositoryMock(); - accessMock = newAccessRepositoryMock(); - auditMock = newAuditRepositoryMock(); - sut = new SyncService(accessMock, assetMock, partnerMock, auditMock); + ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); }); it('should exist', () => { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 1a7a74d699..f85200db48 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,36 +1,20 @@ -import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; import { AssetResponseDto, 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 } from 'src/entities/audit.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { DatabaseAction, EntityType, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; -export class SyncService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IAuditRepository) private auditRepository: IAuditRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - +export class SyncService extends BaseService { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -54,7 +38,7 @@ export class SyncService { return FULL_SYNC; } - await this.access.requirePermission(auth, Permission.TIMELINE_READ, dto.userIds); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index a3b0011d0c..2ad7c78ca2 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,27 +1,25 @@ import { BadRequestException } from '@nestjs/common'; +import { defaults, SystemConfig } from 'src/config'; import { AudioCodec, - CQMode, Colorspace, + CQMode, ImageFormat, LogLevel, - SystemConfig, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, - defaults, -} from 'src/config'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +} from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { DeepPartial } from 'typeorm'; import { Mocked } from 'vitest'; @@ -46,12 +44,19 @@ const updatedConfig = Object.freeze({ [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, [QueueName.NOTIFICATION]: { concurrency: 5 }, }, + backup: { + database: { + enabled: true, + cronExpression: '0 02 * * *', + keepLastAmount: 14, + }, + }, ffmpeg: { crf: 30, threads: 0, preset: 'ultrafast', targetAudioCodec: AudioCodec.AAC, - acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS], + acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS, AudioCodec.PCMS16LE], targetResolution: '720', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], @@ -74,6 +79,11 @@ const updatedConfig = Object.freeze({ enabled: true, level: LogLevel.LOG, }, + metadata: { + faces: { + import: false, + }, + }, machineLearning: { enabled: true, url: 'http://immich-machine-learning:3003', @@ -95,8 +105,8 @@ const updatedConfig = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, @@ -131,11 +141,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, @@ -179,16 +194,14 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - let systemMock: Mocked; + + let configMock: Mocked; let eventMock: Mocked; let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - delete process.env.IMMICH_CONFIG_FILE; - systemMock = newSystemMetadataRepositoryMock(); - eventMock = newEventRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new SystemConfigService(systemMock, eventMock, loggerMock); + ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); }); it('should work', () => { @@ -208,7 +221,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { systemMock.get.mockResolvedValue({}); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -219,25 +232,42 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.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' } })); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ + ffmpeg: expect.objectContaining({ twoPass: false }), + }); + }); + + it('should transform numbers', async () => { + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + systemMock.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ + ffmpeg: expect.objectContaining({ threads: 42 }), + }); + }); + it('should log errors with the config file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(loggerMock.error).toHaveBeenCalledTimes(2); @@ -248,7 +278,7 @@ describe(SystemConfigService.name, () => { }); it('should load the config from a yaml file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` ffmpeg: crf: 30 @@ -261,37 +291,54 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + const externalDomainTests = [ + { should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' }, + { should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + ]; + + for (const { should, externalDomain, result } of externalDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + const partialConfig = { server: { externalDomain } }; + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getSystemConfig(); + expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` unknownOption: true `; systemMock.readFile.mockResolvedValue(partialConfig); - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); }); @@ -306,68 +353,33 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); } else { - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } }); } }); - describe('getStorageTemplateOptions', () => { - it('should send back the datetime variables', () => { - expect(sut.getStorageTemplateOptions()).toEqual({ - dayOptions: ['d', 'dd'], - hourOptions: ['h', 'hh', 'H', 'HH'], - minuteOptions: ['m', 'mm'], - monthOptions: ['M', 'MM', 'MMM', 'MMMM'], - presetOptions: [ - '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}/{{MM}}-{{dd}}/{{filename}}', - '{{y}}/{{MMMM}}-{{dd}}/{{filename}}', - '{{y}}/{{MM}}/{{filename}}', - '{{y}}/{{MMM}}/{{filename}}', - '{{y}}/{{MMMM}}/{{filename}}', - '{{y}}/{{MM}}/{{dd}}/{{filename}}', - '{{y}}/{{MMMM}}/{{dd}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}-{{MM}}-{{dd}}/{{filename}}', - '{{y}}-{{MMM}}-{{dd}}/{{filename}}', - '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}/{{filename}}', - '{{y}}/{{y}}-{{WW}}/{{filename}}', - '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', - '{{y}}/{{y}}-{{MM}}/{{assetId}}', - '{{y}}/{{y}}-{{WW}}/{{assetId}}', - '{{album}}/{{filename}}', - ], - secondOptions: ['s', 'ss', 'SSS'], - weekOptions: ['W', 'WW'], - yearOptions: ['y', 'yy'], - }); - }); - }); - describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); + expect(eventMock.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 () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5aa800a224..8f19b22173 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,52 +1,24 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { LogLevel, SystemConfig, defaults } from 'src/config'; -import { - supportedDayTokens, - supportedHourTokens, - supportedMinuteTokens, - supportedMonthTokens, - supportedPresetTokens, - supportedSecondTokens, - supportedWeekTokens, - supportedYearTokens, -} from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { EventHandlerOptions, OnServerEvent } from 'src/decorators'; -import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { - ClientEvent, - IEventRepository, - OnEvents, - ServerEvent, - SystemConfigUpdateEvent, -} from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { defaults } from 'src/config'; +import { OnEvent } from 'src/decorators'; +import { SystemConfigDto, mapConfig } from 'src/dtos/system-config.dto'; +import { ArgOf } from 'src/interfaces/event.interface'; +import { BaseService } from 'src/services/base.service'; +import { clearConfigCache } from 'src/utils/config'; +import { toPlainObject } from 'src/utils/object'; @Injectable() -export class SystemConfigService implements OnEvents { - private core: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) repository: ISystemMetadataRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SystemConfigService.name); - this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); +export class SystemConfigService extends BaseService { + @OnEvent({ name: 'app.bootstrap', priority: -100 }) + async onBootstrap() { + const config = await this.getConfig({ withCache: false }); + await this.eventRepository.emit('config.update', { newConfig: config }); } - @EventHandlerOptions({ priority: -100 }) - async onBootstrapEvent() { - const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); - } - - async getConfig(): Promise { - const config = await this.core.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); return mapConfig(config); } @@ -54,70 +26,49 @@ export class SystemConfigService implements OnEvents { return mapConfig(defaults); } - onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) { - if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const { logLevel: envLevel } = this.configRepository.getEnv(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + clearConfigCache(); + } + + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { + const { logLevel } = this.configRepository.getEnv(); + if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && logLevel) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } } - async updateConfig(dto: SystemConfigDto): Promise { - if (this.core.isUsingConfigFile()) { + async updateSystemConfig(dto: SystemConfigDto): Promise { + const { configFile } = this.configRepository.getEnv(); + if (configFile) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); } - const oldConfig = await this.core.getConfig({ withCache: false }); + const oldConfig = await this.getConfig({ withCache: false }); try { - await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig }); + await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); } catch (error) { this.logger.warn(`Unable to save system config due to a validation error: ${error}`); throw new BadRequestException(error instanceof Error ? error.message : error); } - const newConfig = await this.core.updateConfig(dto); + const newConfig = await this.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); - await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig }); + await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); } - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { - const options = new SystemConfigTemplateStorageOptionDto(); - - options.dayOptions = supportedDayTokens; - options.weekOptions = supportedWeekTokens; - options.monthOptions = supportedMonthTokens; - options.yearOptions = supportedYearTokens; - options.hourOptions = supportedHourTokens; - options.secondOptions = supportedSecondTokens; - options.minuteOptions = supportedMinuteTokens; - options.presetOptions = supportedPresetTokens; - - return options; - } - async getCustomCss(): Promise { - const { theme } = await this.core.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme.customCss; } - - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - - private getEnvLogLevel() { - return process.env.IMMICH_LOG_LEVEL as LogLevel; - } } diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 9d11c1c72a..3dc2f0a6bb 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,31 +1,60 @@ -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { SystemMetadataKey } from 'src/enum'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SystemMetadataService.name, () => { let sut: SystemMetadataService; - let metadataMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - metadataMock = newSystemMetadataRepositoryMock(); - sut = new SystemMetadataService(metadataMock); + ({ sut, systemMock } = newTestService(SystemMetadataService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('getAdminOnboarding', () => { + it('should get isOnboarded state', async () => { + systemMock.get.mockResolvedValue({ isOnboarded: true }); + await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); + expect(systemMock.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'); + }); + }); + describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(systemMock.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' }); + await expect(sut.getReverseGeocodingState()).resolves.toEqual({ + lastUpdate: '2024-01-01', + lastImportFileName: 'foo.bar', + }); + }); + + it('should default reverse geocoding state to null', async () => { + await expect(sut.getReverseGeocodingState()).resolves.toEqual({ + lastUpdate: null, + lastImportFileName: null, + }); }); }); }); diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts index e8fddfc13c..93449c7a7b 100644 --- a/server/src/services/system-metadata.service.ts +++ b/server/src/services/system-metadata.service.ts @@ -1,29 +1,27 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AdminOnboardingResponseDto, AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto, } from 'src/dtos/system-metadata.dto'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SystemMetadataKey } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class SystemMetadataService { - constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {} - +export class SystemMetadataService extends BaseService { async getAdminOnboarding(): Promise { - const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); return { isOnboarded: false, ...value }; } async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise { - await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: dto.isOnboarded, }); } async getReverseGeocodingState(): Promise { - const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); return { lastUpdate: null, lastImportFileName: null, ...value }; } } diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 4323c061e1..54cef40d04 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,21 +1,24 @@ import { BadRequestException } from '@nestjs/common'; -import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { TagType } from 'src/entities/tag.entity'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { JobStatus } from 'src/interfaces/job.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TagService.name, () => { let sut: TagService; + + let accessMock: IAccessRepositoryMock; let tagMock: Mocked; beforeEach(() => { - tagMock = newTagRepositoryMock(); - sut = new TagService(tagMock); + ({ sut, accessMock, tagMock } = newTestService(TagService)); + + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); it('should work', () => { @@ -30,148 +33,240 @@ describe(TagService.name, () => { }); }); - describe('getById', () => { + describe('get', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(null); + await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); }); it('should return a tag for a user', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.get.mockResolvedValue(tagStub.tag1); + await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1); + expect(tagMock.get).toHaveBeenCalledWith('tag-1'); + }); + }); + + describe('create', () => { + it('should throw an error for no parent tag access', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.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); + await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined(); + expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' })); + }); + + it('should handle invalid parent ids', async () => { + accessMock.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(); }); }); describe('create', () => { it('should throw an error for a duplicate tag', async () => { - tagMock.hasName.mockResolvedValue(true); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + tagMock.getByValue.mockResolvedValue(tagStub.tag1); + 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(); }); it('should create a new tag', async () => { tagMock.create.mockResolvedValue(tagStub.tag1); - await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual( - tagResponseStub.tag1, - ); + await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1); expect(tagMock.create).toHaveBeenCalledWith({ userId: authStub.admin.user.id, - name: 'tag-1', - type: TagType.CUSTOM, + value: 'tag-1', }); }); }); describe('update', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + it('should throw an error for no update permission', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(tagMock.update).not.toHaveBeenCalled(); }); it('should update a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.update.mockResolvedValue(tagStub.tag1); - await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' }); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); + tagMock.update.mockResolvedValue(tagStub.color1); + await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1); + expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' }); + }); + }); + + describe('upsert', () => { + it('should upsert a new tag', async () => { + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ + value: 'Parent', + userId: 'admin_id', + parentId: undefined, + }); + }); + + it('should upsert a nested tag', async () => { + tagMock.getByValue.mockResolvedValueOnce(null); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + value: 'Parent', + userId: 'admin_id', + parent: undefined, + }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + value: 'Parent/Child', + userId: 'admin_id', + parent: expect.objectContaining({ id: '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); + await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + value: 'Parent', + userId: 'admin_id', + parent: undefined, + }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + value: 'Parent/Child', + userId: 'admin_id', + parent: expect.objectContaining({ id: 'tag-parent' }), + }); }); }); describe('remove', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + expect(tagMock.delete).not.toHaveBeenCalled(); }); it('should remove a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); + tagMock.get.mockResolvedValue(tagStub.tag1); await sut.remove(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1); + expect(tagMock.delete).toHaveBeenCalledWith('tag-1'); }); }); - describe('getAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.remove).not.toHaveBeenCalled(); + describe('bulkTagAssets', () => { + it('should handle invalid requests', async () => { + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set()); + tagMock.upsertAssetIds.mockResolvedValue([]); + await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({ + count: 0, + }); + expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]); }); - it('should get the assets for a tag', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.getAssets.mockResolvedValue([assetStub.image]); - await sut.getAssets(authStub.admin, 'tag-1'); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); + 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' }, + ]); + 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' }, + ]); }); }); describe('addAssets', () => { - it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.addAssets).not.toHaveBeenCalled(); + it('should handle invalid ids', async () => { + tagMock.get.mockResolvedValue(null); + tagMock.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(); }); - it('should reject duplicate asset ids and accept new ones', async () => { - tagMock.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + 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'])); await expect( sut.addAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE }, - { assetId: 'asset-2', success: true }, + { id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE }, + { id: 'asset-2', success: true }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); }); }); describe('removeAssets', () => { it('should throw an error for an invalid id', async () => { - tagMock.getById.mockResolvedValue(null); - await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.removeAssets).not.toHaveBeenCalled(); + tagMock.get.mockResolvedValue(null); + tagMock.getAssetIds.mockResolvedValue(new Set()); + 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.getById.mockResolvedValue(tagStub.tag1); - tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1')); + tagMock.get.mockResolvedValue(tagStub.tag1); + tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1'])); await expect( sut.removeAssets(authStub.admin, 'tag-1', { - assetIds: ['asset-1', 'asset-2'], + ids: ['asset-1', 'asset-2'], }), ).resolves.toEqual([ - { assetId: 'asset-1', success: true }, - { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, + { id: 'asset-1', success: true }, + { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1'); - expect(tagMock.hasAsset).toHaveBeenCalledTimes(2); - expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']); + expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); + expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); + }); + }); + + describe('handleTagCleanup', () => { + it('should delete empty tags', async () => { + await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); + expect(tagMock.deleteEmptyTags).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index c04f9b14c4..5534d74efa 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -1,102 +1,143 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto'; -import { ITagRepository } from 'src/interfaces/tag.interface'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, + mapTag, +} from 'src/dtos/tag.dto'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Permission } from 'src/enum'; +import { JobStatus } from 'src/interfaces/job.interface'; +import { AssetTagItem } from 'src/interfaces/tag.interface'; +import { BaseService } from 'src/services/base.service'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { upsertTags } from 'src/utils/tag'; @Injectable() -export class TagService { - constructor(@Inject(ITagRepository) private repository: ITagRepository) {} - - getAll(auth: AuthDto) { - return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag))); +export class TagService extends BaseService { + async getAll(auth: AuthDto) { + const tags = await this.tagRepository.getAll(auth.user.id); + return tags.map((tag) => mapTag(tag)); } - async getById(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [id] }); + const tag = await this.findOrFail(id); return mapTag(tag); } - async create(auth: AuthDto, dto: CreateTagDto) { - const duplicate = await this.repository.hasName(auth.user.id, dto.name); + async create(auth: AuthDto, dto: TagCreateDto) { + let parent: TagEntity | undefined; + if (dto.parentId) { + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + parent = (await this.tagRepository.get(dto.parentId)) || undefined; + if (!parent) { + throw new BadRequestException('Tag not found'); + } + } + + const userId = auth.user.id; + const value = parent ? `${parent.value}/${dto.name}` : dto.name; + const duplicate = await this.tagRepository.getByValue(userId, value); if (duplicate) { throw new BadRequestException(`A tag with that name already exists`); } - const tag = await this.repository.create({ - userId: auth.user.id, - name: dto.name, - type: dto.type, - }); + const tag = await this.tagRepository.create({ userId, value, parent }); return mapTag(tag); } - async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise { - await this.findOrFail(auth, id); - const tag = await this.repository.update({ id, name: dto.name }); + async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] }); + + const { color } = dto; + const tag = await this.tagRepository.update({ id, color }); return mapTag(tag); } + async upsert(auth: AuthDto, dto: TagUpsertDto) { + const tags = await upsertTags(this.tagRepository, { userId: auth.user.id, tags: dto.tags }); + return tags.map((tag) => mapTag(tag)); + } + async remove(auth: AuthDto, id: string): Promise { - const tag = await this.findOrFail(auth, id); - await this.repository.remove(tag); + await this.requireAccess({ auth, permission: Permission.TAG_DELETE, ids: [id] }); + + // TODO sync tag changes for affected assets + + await this.tagRepository.delete(id); } - async getAssets(auth: AuthDto, id: string): Promise { - await this.findOrFail(auth, id); - const assets = await this.repository.getAssets(auth.user.id, id); - return assets.map((asset) => mapAsset(asset)); - } + async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { + const [tagIds, assetIds] = await Promise.all([ + this.checkAccess({ auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + ]); - async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); - - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE }); - } else { - results.push({ assetId, success: true }); + const items: AssetTagItem[] = []; + for (const tagId of tagIds) { + for (const assetId of assetIds) { + items.push({ tagId, assetId }); } } - await this.repository.addAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), + const results = await this.tagRepository.upsertAssetIds(items); + for (const assetId of new Set(results.map((item) => item.assetId))) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + + return { count: results.length }; + } + + async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); + + const results = await addAssets( + auth, + { access: this.accessRepository, bulk: this.tagRepository }, + { parentId: id, assetIds: dto.ids }, ); + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.tag', { assetId }); + } + } + return results; } - async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { - await this.findOrFail(auth, id); + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); - const results: AssetIdsResponseDto[] = []; - for (const assetId of dto.assetIds) { - const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId); - if (hasAsset) { - results.push({ assetId, success: true }); - } else { - results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND }); + const results = await removeAssets( + auth, + { access: this.accessRepository, bulk: this.tagRepository }, + { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, + ); + + for (const { id: assetId, success } of results) { + if (success) { + await this.eventRepository.emit('asset.untag', { assetId }); } } - await this.repository.removeAssets( - auth.user.id, - id, - results.filter((result) => result.success).map((result) => result.assetId), - ); - return results; } - private async findOrFail(auth: AuthDto, id: string) { - const tag = await this.repository.getById(auth.user.id, id); + async handleTagCleanup() { + await this.tagRepository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + + private async findOrFail(id: string) { + const tag = await this.tagRepository.get(id); if (!tag) { throw new BadRequestException('Tag not found'); } diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 981fc11c3f..db6890c27b 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,25 +1,20 @@ import { BadRequestException } from '@nestjs/common'; import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TimelineService.name, () => { let sut: TimelineService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let partnerMock: Mocked; - beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - sut = new TimelineService(accessMock, assetMock, partnerMock); + beforeEach(() => { + ({ sut, accessMock, assetMock } = newTestService(TimelineService)); }); describe('getTimeBuckets', () => { @@ -74,6 +69,70 @@ describe(TimelineService.name, () => { }); }); + it('should include partner shared assets', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: false, + userId: authStub.admin.user.id, + withPartners: true, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: false, + withPartners: true, + userIds: [authStub.admin.user.id], + }); + }); + + it('should check permissions to read tag', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.user.id, + tagId: 'tag-123', + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + tagId: 'tag-123', + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }); + }); + + it('should strip metadata if showExif is disabled', async () => { + accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + const buckets = await sut.getTimeBucket( + { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, + { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + albumId: 'album-id', + }, + ); + expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); + expect(buckets[0]).not.toHaveProperty('exif'); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + albumId: 'album-id', + }); + }); + it('should return the assets for a library time bucket if user has library.read', async () => { assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index b82a16f139..04fd206fe7 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,29 +1,17 @@ -import { BadRequestException, Inject } from '@nestjs/common'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { BadRequestException } 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 { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { Permission } from 'src/enum'; +import { TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; -export class TimelineService { - private accessCore: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private repository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - ) { - this.accessCore = AccessCore.create(accessRepository); - } - +export class TimelineService extends BaseService { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - - return this.repository.getTimeBuckets(timeBucketOptions); + return this.assetRepository.getTimeBuckets(timeBucketOptions); } async getTimeBucket( @@ -32,7 +20,7 @@ export class TimelineService { ): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions); + const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); return !auth.sharedLink || auth.sharedLink?.showExif ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); @@ -59,18 +47,22 @@ export class TimelineService { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { if (dto.albumId) { - await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); if (dto.isArchived !== false) { - await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } + if (dto.tagId) { + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + } + if (dto.withPartners) { const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 73a4f3d57b..748faa14ab 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,34 +1,25 @@ import { BadRequestException } from '@nestjs/common'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; import { TrashService } from 'src/services/trash.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TrashService.name, () => { let sut: TrashService; + let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; let jobMock: Mocked; - let eventMock: Mocked; + let trashMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - - sut = new TrashService(accessMock, assetMock, jobMock, eventMock); + ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); }); describe('restoreAssets', () => { @@ -40,46 +31,70 @@ describe(TrashService.name, () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('should handle an empty list', async () => { + await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); + expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); + }); + it('should restore a batch of assets', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); - expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); expect(jobMock.queue.mock.calls).toEqual([]); }); }); describe('restore', () => { it('should handle an empty trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false }); - await expect(sut.restore(authStub.user1)).resolves.toBeUndefined(); - expect(assetMock.restoreAll).not.toHaveBeenCalled(); - expect(eventMock.clientSend).not.toHaveBeenCalled(); + trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false }); + trashMock.restore.mockResolvedValue(0); + await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); + expect(trashMock.restore).toHaveBeenCalledWith('user-id'); }); - it('should restore and notify', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.restore(authStub.user1)).resolves.toBeUndefined(); - expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]); - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [ - assetStub.image.id, - ]); + it('should restore', async () => { + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + trashMock.restore.mockResolvedValue(1); + await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); + expect(trashMock.restore).toHaveBeenCalledWith('user-id'); }); }); describe('empty', () => { it('should handle an empty trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false }); - await expect(sut.empty(authStub.user1)).resolves.toBeUndefined(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false }); + trashMock.empty.mockResolvedValue(0); + await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); + expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should empty the trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.empty(authStub.user1)).resolves.toBeUndefined(); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + trashMock.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: {} }); + }); + }); + + 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: {} }); + }); + }); + + describe('handleQueueEmptyTrash', () => { + it('should queue asset delete jobs', async () => { + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + { + 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 0c64332941..91c359392e 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,71 +1,71 @@ -import { Inject } from '@nestjs/common'; -import { DateTime } from 'luxon'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface'; +import { TrashResponseDto } from 'src/dtos/trash.dto'; +import { Permission } from 'src/enum'; +import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; import { usePagination } from 'src/utils/pagination'; -export class TrashService { - private access: AccessCore; - - constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - ) { - this.access = AccessCore.create(accessRepository); - } - - async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { +export class TrashService extends BaseService { + async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; - await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids); - await this.restoreAndSend(auth, ids); - } - - async restore(auth: AuthDto): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { - trashedBefore: DateTime.now().toJSDate(), - }), - ); - - for await (const assets of assetPagination) { - const ids = assets.map((a) => a.id); - await this.restoreAndSend(auth, ids); + if (ids.length === 0) { + return { count: 0 }; } + + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); + await this.trashRepository.restoreAll(ids); + await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); + + this.logger.log(`Restored ${ids.length} assets from trash`); + + return { count: ids.length }; } - async empty(auth: AuthDto): Promise { + async restore(auth: AuthDto): Promise { + const count = await this.trashRepository.restore(auth.user.id); + if (count > 0) { + this.logger.log(`Restored ${count} assets from trash`); + } + return { count }; + } + + async empty(auth: AuthDto): Promise { + const count = await this.trashRepository.empty(auth.user.id); + if (count > 0) { + await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + } + return { count }; + } + + @OnEvent({ name: 'assets.delete' }) + async onAssetsDelete() { + await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + } + + async handleQueueEmptyTrash() { + let count = 0; const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { - trashedBefore: DateTime.now().toJSDate(), - }), + this.trashRepository.getDeletedIds(pagination), ); - for await (const assets of assetPagination) { + for await (const assetIds of assetPagination) { + this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`); + count += assetIds.length; await this.jobRepository.queueAll( - assets.map((asset) => ({ + assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { - id: asset.id, + id: assetId, deleteOnDisk: true, }, })), ); } - } - private async restoreAndSend(auth: AuthDto, ids: string[]) { - if (ids.length === 0) { - return; - } + this.logger.log(`Queued ${count} assets for deletion from the trash`); - await this.assetRepository.restoreAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids); + return JobStatus.SUCCESS; } } diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 2479b9826d..70999332dc 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,41 +1,22 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; -import { UserStatus } from 'src/entities/user.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { UserStatus } from 'src/enum'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, describe } from 'vitest'; describe(UserAdminService.name, () => { let sut: UserAdminService; - let albumMock: Mocked; - let cryptoMock: Mocked; - let eventMock: Mocked; + let jobMock: Mocked; - let loggerMock: Mocked; let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new UserAdminService(albumMock, cryptoMock, eventMock, jobMock, userMock, loggerMock); + ({ sut, jobMock, userMock } = newTestService(UserAdminService)); userMock.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index ba829947dc..a4be671c22 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,6 +1,5 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -11,45 +10,32 @@ import { UserAdminUpdateDto, mapUserAdmin, } from 'src/dtos/user.dto'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; -import { UserStatus } from 'src/entities/user.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; +import { UserMetadataKey, UserStatus } from 'src/enum'; +import { JobName } from 'src/interfaces/job.interface'; +import { UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() -export class UserAdminService { - private userCore: UserCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); - this.logger.setContext(UserAdminService.name); - } - +export class UserAdminService extends BaseService { async search(auth: AuthDto, dto: UserAdminSearchDto): Promise { const users = await this.userRepository.getList({ withDeleted: dto.withDeleted }); return users.map((user) => mapUserAdmin(user)); } async create(dto: UserAdminCreateDto): Promise { - const { notify, ...rest } = dto; - const user = await this.userCore.createUser(rest); + const { notify, ...userDto } = dto; + const config = await this.getConfig({ withCache: false }); + if (!config.oauth.enabled && !userDto.password) { + throw new BadRequestException('password is required'); + } - await this.eventRepository.emit('onUserSignupEvent', { + const user = await this.createUser(userDto); + + await this.eventRepository.emit('user.signup', { notify: !!notify, id: user.id, - tempPassword: user.shouldChangePassword ? rest.password : undefined, + tempPassword: user.shouldChangePassword ? userDto.password : undefined, }); return mapUserAdmin(user); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 007b56b212..767d8d8954 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,25 +1,17 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { CacheControl, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +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 { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const makeDeletedAt = (daysAgo: number) => { @@ -30,25 +22,15 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - let userMock: Mocked; - let cryptoRepositoryMock: Mocked; let albumMock: Mocked; let jobMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; + let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - cryptoRepositoryMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new UserService(albumMock, cryptoRepositoryMock, jobMock, storageMock, systemMock, userMock, loggerMock); + ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); userMock.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 03aee5c00b..f67d04cbd3 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,43 +1,23 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; -import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; +import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; +import { IEntityJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; +import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() -export class UserService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class UserService extends BaseService { async search(): Promise { const users = await this.userRepository.getList({ withDeleted: false }); return users.map((user) => mapUser(user)); @@ -92,13 +72,23 @@ export class UserService { return mapUser(user); } - async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise { + async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise { const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false }); - const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path }); + + const user = await this.userRepository.update(auth.user.id, { + profileImagePath: file.path, + profileChangedAt: new Date(), + }); + if (oldpath !== '') { await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } }); } - return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath); + + return { + userId: user.id, + profileImagePath: user.profileImagePath, + profileChangedAt: user.profileChangedAt, + }; } async deleteProfileImage(auth: AuthDto): Promise { @@ -106,7 +96,7 @@ export class UserService { if (user.profileImagePath === '') { throw new BadRequestException("Can't delete a missing profile Image"); } - await this.userRepository.update(auth.user.id, { profileImagePath: '' }); + await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() }); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } }); } @@ -142,16 +132,18 @@ export class UserService { throw new BadRequestException('Invalid license key'); } + const { licensePublicKey } = this.configRepository.getEnv(); + const clientLicenseValid = this.cryptoRepository.verifySha256( license.licenseKey, license.activationKey, - getClientLicensePublicKey(), + licensePublicKey.client, ); const serverLicenseValid = this.cryptoRepository.verifySha256( license.licenseKey, license.activationKey, - getServerLicensePublicKey(), + licensePublicKey.server, ); if (!clientLicenseValid && !serverLicenseValid) { @@ -178,7 +170,7 @@ export class UserService { async handleUserDeleteCheck(): Promise { const users = await this.userRepository.getDeletedUsers(); - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.jobRepository.queueAll( users.flatMap((user) => this.isReadyForDeletion(user, config.user.deleteDelay) @@ -190,7 +182,7 @@ export class UserService { } async handleUserDelete({ id, force }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return JobStatus.FAILED; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 74489e04ea..46f8f620c4 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,17 +1,17 @@ import { DateTime } from 'luxon'; +import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { VersionService } from 'src/services/version.service'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const mockRelease = (version: string) => ({ @@ -26,26 +26,41 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; + + let configMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; - let serverMock: Mocked; - let systemMock: Mocked; let loggerMock: Mocked; + let serverInfoMock: Mocked; + let systemMock: Mocked; + let versionHistoryMock: Mocked; beforeEach(() => { - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - serverMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock); + ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = + newTestService(VersionService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onBootstrap', () => { + it('should record a new version', async () => { + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + }); + + it('should skip a duplicate version', async () => { + versionHistoryMock.getLatest.mockResolvedValue({ + id: 'version-1', + createdAt: new Date(), + version: serverVersion.toString(), + }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(versionHistoryMock.create).not.toHaveBeenCalled(); + }); + }); + describe('getVersion', () => { it('should respond the server version', () => { expect(sut.getVersion()).toEqual({ @@ -56,6 +71,14 @@ 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]); + await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); + }); + }); + describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); @@ -65,11 +88,11 @@ describe(VersionService.name, () => { describe('handVersionCheck', () => { beforeEach(() => { - process.env.IMMICH_ENV = 'production'; + configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); }); it('should not run in dev mode', async () => { - process.env.IMMICH_ENV = 'development'; + configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); @@ -81,8 +104,13 @@ describe(VersionService.name, () => { await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); + it('should not run if version check is disabled', async () => { + systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + }); + it('should run if it has been > 60 minutes', async () => { - serverMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); + serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); systemMock.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', @@ -94,7 +122,7 @@ describe(VersionService.name, () => { }); it('should not notify if the version is equal', async () => { - serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { checkedAt: expect.any(String), @@ -104,11 +132,26 @@ describe(VersionService.name, () => { }); it('should handle a github error', async () => { - serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + serverInfoMock.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(); }); }); + + 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); + }); + + it('should also send a new release notification', async () => { + systemMock.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)); + }); + }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 8408e53bfe..231ced1a95 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -1,16 +1,15 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; -import { isDev, serverVersion } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; +import { serverVersion } from 'src/constants'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity'; -import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +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 } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { @@ -22,28 +21,29 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService implements OnEvents { - private configCore: SystemConfigCore; - - constructor( - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IServerInfoRepository) private repository: IServerInfoRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(VersionService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - async onBootstrapEvent(): Promise { +export class VersionService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(): Promise { await this.handleVersionCheck(); + + await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => { + const latest = await this.versionRepository.getLatest(); + const current = serverVersion.toString(); + if (!latest || latest.version !== current) { + this.logger.log(`Version has changed, adding ${current} to history`); + await this.versionRepository.create({ version: current }); + } + }); } getVersion() { return ServerVersionResponseDto.fromSemVer(serverVersion); } + getVersionHistory() { + return this.versionRepository.getAll(); + } + async handleQueueVersionCheck() { await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); } @@ -52,11 +52,12 @@ export class VersionService implements OnEvents { try { this.logger.debug('Running version check'); - if (isDev()) { + const { environment } = this.configRepository.getEnv(); + if (environment === ImmichEnvironment.DEVELOPMENT) { return JobStatus.SKIPPED; } - const { newVersionCheck } = await this.configCore.getConfig({ withCache: true }); + const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { return JobStatus.SKIPPED; } @@ -71,14 +72,15 @@ export class VersionService implements OnEvents { } } - const { tag_name: releaseVersion, published_at: publishedAt } = await this.repository.getGitHubRelease(); + const { tag_name: releaseVersion, published_at: publishedAt } = + await this.serverInfoRepository.getGitHubRelease(); const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion }; await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata); if (semver.gt(releaseVersion, serverVersion)) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); - this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata)); + this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}`, error?.stack); @@ -88,12 +90,12 @@ export class VersionService implements OnEvents { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { - this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { + this.eventRepository.clientSend('on_server_version', userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { - this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata)); + this.eventRepository.clientSend('on_new_release', userId, asNotification(metadata)); } } } diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts new file mode 100644 index 0000000000..e9373ce66f --- /dev/null +++ b/server/src/services/view.service.spec.ts @@ -0,0 +1,52 @@ +import { mapAsset } from 'src/dtos/asset-response.dto'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { ViewService } from 'src/services/view.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newTestService } from 'test/utils'; + +import { Mocked } from 'vitest'; + +describe(ViewService.name, () => { + let sut: ViewService; + let viewMock: Mocked; + + beforeEach(() => { + ({ sut, viewMock } = newTestService(ViewService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getUniqueOriginalPaths', () => { + it('should return unique original paths', async () => { + const mockPaths = ['path1', 'path2', 'path3']; + viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + + const result = await sut.getUniqueOriginalPaths(authStub.admin); + + expect(result).toEqual(mockPaths); + expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + }); + }); + + describe('getAssetsByOriginalPath', () => { + it('should return assets by original path', async () => { + const path = '/asset'; + + const asset1 = { ...assetStub.image, originalPath: '/asset/path1' }; + const asset2 = { ...assetStub.image, originalPath: '/asset/path2' }; + + const mockAssets = [asset1, asset2]; + + const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); + + viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + + const result = await sut.getAssetsByOriginalPath(authStub.admin, path); + expect(result).toEqual(mockAssetReponseDto); + await expect(viewMock.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 new file mode 100644 index 0000000000..cb80536870 --- /dev/null +++ b/server/src/services/view.service.ts @@ -0,0 +1,14 @@ +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { BaseService } from 'src/services/base.service'; + +export class ViewService extends BaseService { + getUniqueOriginalPaths(auth: AuthDto): Promise { + return this.viewRepository.getUniqueOriginalPaths(auth.user.id); + } + + async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { + const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); + return assets.map((asset) => mapAsset(asset, { auth })); + } +} diff --git a/server/src/subscribers/audit.subscriber.ts b/server/src/subscribers/audit.subscriber.ts index 3d65507aec..8c2ad3e18d 100644 --- a/server/src/subscribers/audit.subscriber.ts +++ b/server/src/subscribers/audit.subscriber.ts @@ -1,6 +1,7 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AuditEntity, DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { AuditEntity } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm'; @EventSubscriber() diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts new file mode 100644 index 0000000000..d3219a1a6c --- /dev/null +++ b/server/src/utils/access.ts @@ -0,0 +1,293 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { AlbumUserRole, Permission } from 'src/enum'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; + +export type GrantedRequest = { + requested: Permission[]; + current: Permission[]; +}; + +export const isGranted = ({ requested, current }: GrantedRequest) => { + if (current.includes(Permission.ALL)) { + return true; + } + + return setIsSuperset(new Set(current), new Set(requested)); +}; + +export type AccessRequest = { + auth: AuthDto; + permission: Permission; + ids: Set | string[]; +}; + +type SharedLinkAccessRequest = { sharedLink: SharedLinkEntity; permission: Permission; ids: Set }; +type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; + +export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { + if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { + throw new UnauthorizedException(); + } + return auth; +}; + +export const requireAccess = async (access: IAccessRepository, request: AccessRequest) => { + const allowedIds = await checkAccess(access, request); + if (!setIsEqual(new Set(request.ids), allowedIds)) { + throw new BadRequestException(`Not found or no ${request.permission} access`); + } +}; + +export const checkAccess = async ( + access: IAccessRepository, + { ids, auth, permission }: AccessRequest, +): Promise> => { + const idSet = Array.isArray(ids) ? new Set(ids) : ids; + if (idSet.size === 0) { + return new Set(); + } + + return auth.sharedLink + ? checkSharedLinkAccess(access, { sharedLink: auth.sharedLink, permission, ids: idSet }) + : checkOtherAccess(access, { auth, permission, ids: idSet }); +}; + +const checkSharedLinkAccess = async ( + access: IAccessRepository, + request: SharedLinkAccessRequest, +): Promise> => { + const { sharedLink, permission, ids } = request; + const sharedLinkId = sharedLink.id; + + switch (permission) { + case Permission.ASSET_READ: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_VIEW: { + return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ASSET_DOWNLOAD: { + return sharedLink.allowDownload ? await access.asset.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ASSET_UPLOAD: { + return sharedLink.allowUpload ? ids : new Set(); + } + + case Permission.ASSET_SHARE: { + // TODO: fix this to not use sharedLink.userId for access control + return await access.asset.checkOwnerAccess(sharedLink.userId, ids); + } + + case Permission.ALBUM_READ: { + return await access.album.checkSharedLinkAccess(sharedLinkId, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + return sharedLink.allowDownload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + case Permission.ALBUM_ADD_ASSET: { + return sharedLink.allowUpload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); + } + + default: { + return new Set(); + } + } +}; + +const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise> => { + const { auth, permission, ids } = request; + + switch (permission) { + // uses album id + case Permission.ACTIVITY_CREATE: { + return await access.activity.checkCreateAccess(auth.user.id, ids); + } + + // uses activity id + case Permission.ACTIVITY_DELETE: { + const isOwner = await access.activity.checkOwnerAccess(auth.user.id, ids); + const isAlbumOwner = await access.activity.checkAlbumOwnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isAlbumOwner); + } + + case Permission.ASSET_READ: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_SHARE: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.ASSET_VIEW: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_DOWNLOAD: { + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); + const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); + return setUnion(isOwner, isAlbum, isPartner); + } + + case Permission.ASSET_UPDATE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ASSET_DELETE: { + return await access.asset.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_READ: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_ADD_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_UPDATE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DELETE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_SHARE: { + return await access.album.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ALBUM_DOWNLOAD: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.VIEWER, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ALBUM_REMOVE_ASSET: { + const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.album.checkSharedAlbumAccess( + auth.user.id, + setDifference(ids, isOwner), + AlbumUserRole.EDITOR, + ); + return setUnion(isOwner, isShared); + } + + case Permission.ASSET_UPLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.ARCHIVE_READ: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.AUTH_DEVICE_DELETE: { + return await access.authDevice.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.TAG_ASSET: + case Permission.TAG_READ: + case Permission.TAG_UPDATE: + case Permission.TAG_DELETE: { + return await access.tag.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.TIMELINE_READ: { + const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); + return setUnion(isOwner, isPartner); + } + + case Permission.TIMELINE_DOWNLOAD: { + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); + } + + case Permission.MEMORY_READ: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_UPDATE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.MEMORY_DELETE: { + return access.memory.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_READ: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_UPDATE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_MERGE: { + return await access.person.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_CREATE: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PERSON_REASSIGN: { + return access.person.checkFaceOwnerAccess(auth.user.id, ids); + } + + case Permission.PARTNER_UPDATE: { + return await access.partner.checkUpdateAccess(auth.user.id, ids); + } + + case Permission.STACK_READ: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_UPDATE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.STACK_DELETE: { + return access.stack.checkOwnerAccess(auth.user.id, ids); + } + + default: { + return new Set(); + } + } +}; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 76a8dc06b0..44c291e139 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,8 +1,14 @@ -import { AccessCore, Permission } from 'src/cores/access.core'; +import { BadRequestException } from '@nestjs/common'; +import { StorageCore } from 'src/cores/storage.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFileType, AssetType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { checkAccess } from 'src/utils/access'; export interface IBulkAsset { getAssetIds: (id: string, assetIds: string[]) => Promise>; @@ -10,17 +16,28 @@ export interface IBulkAsset { removeAssetIds: (id: string, assetIds: string[]) => Promise; } +const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { + return (files || []).find((file) => file.type === type); +}; + +export const getAssetFiles = (files?: AssetFileEntity[]) => ({ + previewFile: getFileByType(files, AssetFileType.PREVIEW), + thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), +}); + export const addAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[] }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); - - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const { access, bulk } = repositories; + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); - const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const allowedAssetIds = await checkAccess(access, { + auth, + permission: Permission.ASSET_SHARE, + ids: notPresentAssetIds, + }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -42,7 +59,7 @@ export const addAssets = async ( const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); if (newAssetIds.length > 0) { - await repository.addAssetIds(dto.parentId, newAssetIds); + await bulk.addAssetIds(dto.parentId, newAssetIds); } return results; @@ -50,18 +67,17 @@ export const addAssets = async ( export const removeAssets = async ( auth: AuthDto, - repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + repositories: { access: IAccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission }, ) => { - const { accessRepository, repository } = repositories; - const access = AccessCore.create(accessRepository); + const { access, bulk } = repositories; // check if the user can always remove from the parent album, memory, etc. - const canAlwaysRemove = await access.checkAccess(auth, dto.canAlwaysRemove, [dto.parentId]); - const existingAssetIds = await repository.getAssetIds(dto.parentId, dto.assetIds); + const canAlwaysRemove = await checkAccess(access, { auth, permission: dto.canAlwaysRemove, ids: [dto.parentId] }); + const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const allowedAssetIds = canAlwaysRemove.has(dto.parentId) ? existingAssetIds - : await access.checkAccess(auth, Permission.ASSET_SHARE, existingAssetIds); + : await checkAccess(access, { auth, permission: Permission.ASSET_SHARE, ids: existingAssetIds }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -83,7 +99,7 @@ export const removeAssets = async ( const removedIds = results.filter(({ success }) => success).map(({ id }) => id); if (removedIds.length > 0) { - await repository.removeAssetIds(dto.parentId, removedIds); + await bulk.removeAssetIds(dto.parentId, removedIds); } return results; @@ -118,3 +134,50 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; + +export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; + +export const onBeforeLink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + const motionAsset = await assetRepository.getById(livePhotoVideoId); + if (!motionAsset) { + throw new BadRequestException('Live photo video not found'); + } + if (motionAsset.type !== AssetType.VIDEO) { + throw new BadRequestException('Live photo video must be a video'); + } + if (motionAsset.ownerId !== userId) { + throw new BadRequestException('Live photo video does not belong to the user'); + } + + if (motionAsset?.isVisible) { + await assetRepository.update({ id: livePhotoVideoId, isVisible: false }); + await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); + } +}; + +export const onBeforeUnlink = async ( + { asset: assetRepository }: AssetHookRepositories, + { livePhotoVideoId }: { livePhotoVideoId: string }, +) => { + const motion = await assetRepository.getById(livePhotoVideoId); + if (!motion) { + return null; + } + + if (StorageCore.isAndroidMotionPath(motion.originalPath)) { + throw new BadRequestException('Cannot unlink Android motion photos'); + } + + return motion; +}; + +export const onAfterUnlink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + await assetRepository.update({ id: livePhotoVideoId, isVisible: true }); + await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId }); +}; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts new file mode 100644 index 0000000000..ce8a2da839 --- /dev/null +++ b/server/src/utils/config.ts @@ -0,0 +1,132 @@ +import AsyncLock from 'async-lock'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +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 { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; + +export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; + +type RepoDeps = { + configRepo: IConfigRepository; + metadataRepo: ISystemMetadataRepository; + logger: ILoggerRepository; +}; + +const asyncLock = new AsyncLock(); +let config: SystemConfig | null = null; +let lastUpdated: number | null = null; + +export const clearConfigCache = () => { + config = null; + lastUpdated = null; +}; + +export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise => { + if (!withCache || !config) { + const timestamp = lastUpdated; + await asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (timestamp === lastUpdated) { + config = await buildConfig(repos); + lastUpdated = Date.now(); + } + }); + } + + return config!; +}; + +export const updateConfig = async (repos: RepoDeps, newConfig: SystemConfig): Promise => { + const { metadataRepo } = repos; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); + + if (isEmpty || isEqual) { + continue; + } + + _.set(partialConfig, property, newValue); + } + + await metadataRepo.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + + return getConfig(repos, { withCache: false }); +}; + +const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string) => { + try { + const file = await metadataRepo.readFile(filepath); + return loadYaml(file.toString()) as unknown; + } catch (error: Error | any) { + logger.error(`Unable to load configuration file: ${filepath}`); + logger.error(error); + throw error; + } +}; + +const buildConfig = async (repos: RepoDeps) => { + const { configRepo, metadataRepo, logger } = repos; + const { configFile } = configRepo.getEnv(); + + // load partial + const partial = configFile + ? await loadFromFile(repos, configFile) + : await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG); + + // merge with defaults + const rawConfig = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(rawConfig, property, _.get(partial, property)); + } + + // check for extra properties + const unknownKeys = _.cloneDeep(rawConfig); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config + const instance = plainToInstance(SystemConfigDto, rawConfig); + const errors = await validate(instance); + if (errors.length > 0) { + if (configFile) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + logger.error('Validation error', errors); + } + } + + // return config with class-transform changes + const config = instanceToPlain(instance) as SystemConfig; + + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 944978bddd..55e4fcb0e5 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; import { Between, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm'; @@ -44,11 +45,19 @@ export function searchAssetBuilder( } if (hasExifQuery) { - options.withExif - ? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo') - : builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); + if (options.withExif) { + builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); + } else { + builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); + } - builder.andWhere({ exifInfo }); + for (const [key, value] of Object.entries(exifInfo)) { + if (value === null) { + builder.andWhere(`exifInfo.${key} IS NULL`); + } else { + builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value }); + } + } } const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']); @@ -63,7 +72,7 @@ export function searchAssetBuilder( builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } - const path = _.pick(options, ['encodedVideoPath', 'originalPath', 'previewPath', 'thumbnailPath']); + const path = _.pick(options, ['encodedVideoPath', 'originalPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); if (options.originalFileName) { @@ -72,7 +81,7 @@ export function searchAssetBuilder( }); } - const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']); + const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); const { isArchived, isEncoded, @@ -83,7 +92,6 @@ export function searchAssetBuilder( withPeople, withSmartInfo, personIds, - withExif, withStacked, trashedAfter, trashedBefore, @@ -112,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { @@ -120,15 +128,16 @@ export function searchAssetBuilder( } if (personIds && personIds.length > 0) { - builder - .leftJoin(`${builder.alias}.faces`, 'faces') - .andWhere('faces.personId IN (:...personIds)', { personIds }) - .addGroupBy(`${builder.alias}.id`) - .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length }); + const cte = builder + .createQueryBuilder() + .select('faces."assetId"') + .from(AssetFaceEntity, 'faces') + .where('faces."personId" IN (:...personIds)', { personIds }) + .groupBy(`faces."assetId"`) + .having(`COUNT(DISTINCT faces."personId") = :personCount`, { personCount: personIds.length }); + builder.addCommonTableExpression(cte, 'face_ids').innerJoin('face_ids', 'a', 'a."assetId" = asset.id'); - if (withExif) { - builder.addGroupBy('exifInfo.assetId'); - } + builder.getQuery(); // typeorm mixes up parameters without this (੭ °ཀ°)੭ } if (withStacked) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts deleted file mode 100644 index 1bee4c6558..0000000000 --- a/server/src/utils/events.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ModuleRef, Reflector } from '@nestjs/core'; -import _ from 'lodash'; -import { HandlerOptions } from 'src/decorators'; -import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; -import { services } from 'src/services'; - -export const setupEventHandlers = (moduleRef: ModuleRef) => { - const reflector = moduleRef.get(Reflector, { strict: false }); - const repository = moduleRef.get(IEventRepository); - const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler; priority: number }> = []; - - // discovery - for (const Service of services) { - const instance = moduleRef.get(Service); - for (const event of events) { - const handler = instance[event] as EmitEventHandler; - if (typeof handler !== 'function') { - continue; - } - - const options = reflector.get(Metadata.EVENT_HANDLER_OPTIONS, handler); - const priority = options?.priority || 0; - - handlers.push({ event, handler: handler.bind(instance), priority }); - } - } - - // register by priority - for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) { - repository.on(event, handler); - } -}; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 53a4d571dc..3b26c3e1ba 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -3,6 +3,7 @@ import { NextFunction, Response } from 'express'; 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { isConnectionAborted } from 'src/utils/misc'; @@ -19,12 +20,6 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string return getFileNameWithoutExtension(stillName) + extname(motionName); } -export enum CacheControl { - PRIVATE_WITH_CACHE = 'private_with_cache', - PRIVATE_WITHOUT_CACHE = 'private_without_cache', - NONE = 'none', -} - export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; diff --git a/server/src/utils/instrumentation.ts b/server/src/utils/instrumentation.ts deleted file mode 100644 index 484ba5901c..0000000000 --- a/server/src/utils/instrumentation.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; -import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; -import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'; -import { NodeSDK, contextBase, metrics, resources } from '@opentelemetry/sdk-node'; -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; -import { snakeCase, startCase } from 'lodash'; -import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; -import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; -import { performance } from 'node:perf_hooks'; -import { excludePaths, serverVersion } from 'src/constants'; -import { DecorateAll } from 'src/decorators'; - -let metricsEnabled = process.env.IMMICH_METRICS === 'true'; -export const hostMetrics = - process.env.IMMICH_HOST_METRICS == null ? metricsEnabled : process.env.IMMICH_HOST_METRICS === 'true'; -export const apiMetrics = - process.env.IMMICH_API_METRICS == null ? metricsEnabled : process.env.IMMICH_API_METRICS === 'true'; -export const repoMetrics = - process.env.IMMICH_IO_METRICS == null ? metricsEnabled : process.env.IMMICH_IO_METRICS === 'true'; -export const jobMetrics = - process.env.IMMICH_JOB_METRICS == null ? metricsEnabled : process.env.IMMICH_JOB_METRICS === 'true'; - -metricsEnabled ||= hostMetrics || apiMetrics || repoMetrics || jobMetrics; -if (!metricsEnabled && process.env.OTEL_SDK_DISABLED === undefined) { - process.env.OTEL_SDK_DISABLED = 'true'; -} - -const aggregation = new metrics.ExplicitBucketHistogramAggregation( - [0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000], - true, -); - -let otelSingleton: NodeSDK | undefined; - -export const otelStart = (port: number) => { - if (otelSingleton) { - throw new Error('OpenTelemetry SDK already started'); - } - otelSingleton = new NodeSDK({ - resource: new resources.Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: `immich`, - [SemanticResourceAttributes.SERVICE_VERSION]: serverVersion.toString(), - }), - metricReader: new PrometheusExporter({ port }), - contextManager: new AsyncLocalStorageContextManager(), - instrumentations: [ - new HttpInstrumentation(), - new IORedisInstrumentation(), - new NestInstrumentation(), - new PgInstrumentation(), - ], - views: [new metrics.View({ aggregation, instrumentName: '*', instrumentUnit: 'ms' })], - }); - otelSingleton.start(); -}; - -export const otelShutdown = async () => { - if (otelSingleton) { - await otelSingleton.shutdown(); - otelSingleton = undefined; - } -}; - -export const otelConfig: OpenTelemetryModuleOptions = { - metrics: { - hostMetrics, - apiMetrics: { - enable: apiMetrics, - ignoreRoutes: excludePaths, - }, - }, -}; - -function ExecutionTimeHistogram({ - description, - unit = 'ms', - valueType = contextBase.ValueType.DOUBLE, -}: contextBase.MetricOptions = {}) { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - if (!repoMetrics || process.env.OTEL_SDK_DISABLED) { - return; - } - - const method = descriptor.value; - const className = target.constructor.name as string; - const propertyName = String(propertyKey); - const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${snakeCase(propertyName)}.duration`; - - const metricDescription = - description ?? - `The elapsed time in ${unit} for the ${startCase(className)} to ${startCase(propertyName).toLowerCase()}`; - - let histogram: contextBase.Histogram | undefined; - - descriptor.value = function (...args: any[]) { - const start = performance.now(); - const result = method.apply(this, args); - - void Promise.resolve(result) - .then(() => { - const end = performance.now(); - if (!histogram) { - histogram = contextBase.metrics - .getMeter('immich') - .createHistogram(metricName, { description: metricDescription, unit, valueType }); - } - histogram.record(end - start, {}); - }) - .catch(() => { - // noop - }); - - return result; - }; - - copyMetadataFromFunctionToFunction(method, descriptor.value); - }; -} - -export const Instrumentation = () => DecorateAll(ExecutionTimeHistogram()); diff --git a/server/src/utils/logger-colors.ts b/server/src/utils/logger-colors.ts deleted file mode 100644 index 36104ee520..0000000000 --- a/server/src/utils/logger-colors.ts +++ /dev/null @@ -1,17 +0,0 @@ -type ColorTextFn = (text: string) => string; - -const isColorAllowed = () => !process.env.NO_COLOR; -const colorIfAllowed = (colorFn: ColorTextFn) => (text: string) => (isColorAllowed() ? colorFn(text) : text); - -export const LogColor = { - red: colorIfAllowed((text: string) => `\u001B[31m${text}\u001B[39m`), - green: colorIfAllowed((text: string) => `\u001B[32m${text}\u001B[39m`), - yellow: colorIfAllowed((text: string) => `\u001B[33m${text}\u001B[39m`), - blue: colorIfAllowed((text: string) => `\u001B[34m${text}\u001B[39m`), - magentaBright: colorIfAllowed((text: string) => `\u001B[95m${text}\u001B[39m`), - cyanBright: colorIfAllowed((text: string) => `\u001B[96m${text}\u001B[39m`), -}; - -export const LogStyle = { - bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`), -}; diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts new file mode 100644 index 0000000000..2e33a7bcb5 --- /dev/null +++ b/server/src/utils/logger.ts @@ -0,0 +1,22 @@ +import { HttpException } from '@nestjs/common'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { TypeORMError } from 'typeorm'; + +export const logGlobalError = (logger: ILoggerRepository, error: Error) => { + if (error instanceof HttpException) { + const status = error.getStatus(); + const response = error.getResponse(); + logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`); + return; + } + + if (error instanceof TypeORMError) { + logger.error(`Database error: ${error}`); + return; + } + + if (error instanceof Error) { + logger.error(`Unknown error: ${error}`); + return; + } +}; diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index cf8e438349..03d57296d8 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ -import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/config'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/enum'; import { AudioStreamInfo, BitrateDistribution, @@ -52,7 +52,9 @@ export class BaseConfig implements VideoCodecSWConfig { break; } case TranscodeHWAccel.VAAPI: { - handler = new VAAPIConfig(config, devices); + handler = config.accelDecode + ? new VaapiHwDecodeConfig(config, devices) + : new VaapiSwDecodeConfig(config, devices); break; } case TranscodeHWAccel.RKMPP: { @@ -80,6 +82,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); @@ -268,9 +271,9 @@ export class BaseConfig implements VideoCodecSWConfig { getColors() { return { - primaries: 'bt709', - transfer: 'bt709', - matrix: 'bt709', + primaries: '709', + transfer: '709', + matrix: '709', }; } @@ -422,16 +425,16 @@ export class ThumbnailConfig extends BaseConfig { getScaling(videoStream: VideoStreamInfo) { let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int'; if (!this.shouldToneMap(videoStream)) { - options += ':out_color_matrix=601:out_range=pc'; + options += ':out_color_matrix=bt601:out_range=pc'; } return options; } getColors() { return { - primaries: 'bt709', + primaries: '709', transfer: '601', - matrix: 'bt470bg', + matrix: '470bg', }; } } @@ -490,6 +493,10 @@ export class VP9Config extends BaseConfig { } export class AV1Config extends BaseConfig { + getVideoCodec(): string { + return 'libsvtav1'; + } + getPresetOptions() { const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8 if (speed >= 0) { @@ -644,6 +651,14 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig { getOutputThreadOptions() { return []; } + + getColors() { + return { + primaries: 'bt709', + transfer: 'bt709', + matrix: 'bt709', + }; + } } export class QsvSwDecodeConfig extends BaseHWConfig { @@ -674,7 +689,7 @@ export class QsvSwDecodeConfig extends BaseHWConfig { const options = this.getToneMapping(videoStream); options.push('format=nv12', 'hwupload=extra_hw_frames=64'); if (this.shouldScale(videoStream)) { - options.push(`scale_qsv=${this.getScaling(videoStream)}`); + options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq`); } return options; } @@ -787,9 +802,17 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { getInputThreadOptions() { return [`-threads 1`]; } + + getColors() { + return { + primaries: 'bt709', + transfer: 'bt709', + matrix: 'bt709', + }; + } } -export class VAAPIConfig extends BaseHWConfig { +export class VaapiSwDecodeConfig extends BaseHWConfig { getBaseInputOptions() { if (this.devices.length === 0) { throw new Error('No VAAPI device found'); @@ -807,7 +830,7 @@ export class VAAPIConfig extends BaseHWConfig { const options = this.getToneMapping(videoStream); options.push('format=nv12', 'hwupload'); if (this.shouldScale(videoStream)) { - options.push(`scale_vaapi=${this.getScaling(videoStream)}`); + options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`); } return options; @@ -856,6 +879,76 @@ export class VAAPIConfig extends BaseHWConfig { } } +export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig { + getBaseInputOptions() { + if (this.devices.length === 0) { + throw new Error('No VAAPI device found'); + } + + const options = [ + '-hwaccel vaapi', + '-hwaccel_output_format vaapi', + '-noautorotate', + ...this.getInputThreadOptions(), + ]; + const hwDevice = this.getPreferredHardwareDevice(); + if (hwDevice) { + options.push(`-hwaccel_device ${hwDevice}`); + } + + return options; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) { + let scaling = `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`; + if (!this.shouldToneMap(videoStream)) { + scaling += ':format=nv12'; + } + options.push(scaling); + } + + options.push(...this.getToneMapping(videoStream)); + return options; + } + + getToneMapping(videoStream: VideoStreamInfo): string[] { + if (!this.shouldToneMap(videoStream)) { + return []; + } + + const colors = this.getColors(); + const tonemapOptions = [ + 'desat=0', + 'format=nv12', + `matrix=${colors.matrix}`, + `primaries=${colors.primaries}`, + 'range=pc', + `tonemap=${this.config.tonemap}`, + `transfer=${colors.transfer}`, + ]; + + return [ + 'hwmap=derive_device=opencl', + `tonemap_opencl=${tonemapOptions.join(':')}`, + 'hwmap=derive_device=vaapi:reverse=1,format=vaapi', + ]; + } + + getInputThreadOptions() { + return [`-threads 1`]; + } + + getColors() { + return { + primaries: 'bt709', + transfer: 'bt709', + matrix: 'bt709', + }; + } +} + export class RkmppSwDecodeConfig extends BaseHWConfig { constructor( protected config: SystemConfigFFmpegDto, @@ -934,4 +1027,12 @@ export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { } return []; } + + getColors() { + return { + primaries: 'bt709', + transfer: 'bt709', + matrix: 'bt709', + }; + } } diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 996ea6c744..50fe760a04 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -30,6 +30,7 @@ describe('mimeTypes', () => { { mimetype: 'image/kdc', extension: '.kdc' }, { mimetype: 'image/mrw', extension: '.mrw' }, { mimetype: 'image/nef', extension: '.nef' }, + { mimetype: 'image/nrw', extension: '.nrw' }, { mimetype: 'image/orf', extension: '.orf' }, { mimetype: 'image/ori', extension: '.ori' }, { mimetype: 'image/pef', extension: '.pef' }, diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 495efc9ebc..cbf6e5b489 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -1,5 +1,5 @@ import { extname } from 'node:path'; -import { AssetType } from 'src/entities/asset.entity'; +import { AssetType } from 'src/enum'; const raw: Record = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], @@ -19,6 +19,7 @@ const raw: Record = { '.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'], '.nef': ['image/nef', 'image/x-nikon-nef'], + '.nrw': ['image/nrw', 'image/x-nikon-nrw'], '.orf': ['image/orf', 'image/x-olympus-orf'], '.ori': ['image/ori', 'image/x-olympus-ori'], '.pef': ['image/pef', 'image/x-pentax-pef'], diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 6063b4925c..6e435e68a8 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -11,10 +11,12 @@ import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { SystemConfig } from 'src/config'; -import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants'; -import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; +import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Metadata } from 'src/middleware/auth.guard'; + +export const getExternalDomain = (server: SystemConfig['server'], port: number) => + server.externalDomain || `http://localhost:${port}`; /** * @returns a list of strings representing the keys of the object in dot notation @@ -64,6 +66,7 @@ export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machin isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled; export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) => isSmartSearchEnabled(machineLearning) && machineLearning.duplicateDetection.enabled; +export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metadata.faces.import; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; @@ -192,7 +195,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const useSwagger = (app: INestApplication, force = false) => { +export const useSwagger = (app: INestApplication, { write }: { write: boolean }) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') @@ -209,7 +212,7 @@ export const useSwagger = (app: INestApplication, force = false) => { in: 'header', name: ImmichHeader.API_KEY, }, - Metadata.API_KEY_SECURITY, + MetadataKey.API_KEY_SECURITY, ) .addServer('/api') .build(); @@ -229,7 +232,7 @@ export const useSwagger = (app: INestApplication, force = false) => { SwaggerModule.setup('doc', app, specification, customOptions); - if (isDev() || force) { + if (write) { // Generate API Documentation only in development mode const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); diff --git a/server/src/utils/object.ts b/server/src/utils/object.ts new file mode 100644 index 0000000000..25ae42cba8 --- /dev/null +++ b/server/src/utils/object.ts @@ -0,0 +1,15 @@ +import { isEqual, isPlainObject } from 'lodash'; + +/** + * Deeply clones and converts a class instance to a plain object. + */ +export function toPlainObject(obj: T): T { + return isPlainObject(obj) ? obj : structuredClone(obj); +} + +/** + * Performs a deep comparison between objects, converting them to plain objects first if needed. + */ +export function isEqualObject(value: object, other: object): boolean { + return isEqual(toPlainObject(value), toPlainObject(other)); +} diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index dec1a9de0c..4009f219c1 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { PaginationMode } from 'src/enum'; import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; export interface PaginationOptions { @@ -6,11 +7,6 @@ export interface PaginationOptions { skip?: number; } -export enum PaginationMode { - LIMIT_OFFSET = 'limit-offset', - SKIP_TAKE = 'skip-take', -} - export interface PaginatedBuilderOptions { take: number; skip?: number; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index f3561fa7b6..beaeb472ec 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,7 +1,8 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; +import { UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserMetadataKey } from 'src/enum'; import { getKeysDeep } from 'src/utils/misc'; import { DeepPartial } from 'typeorm'; diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index f6edb2f8b3..19d3cac661 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; -export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param); +export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index f318ca3300..679d947afb 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -1,6 +1,7 @@ import { CookieOptions, Response } from 'express'; import { Duration } from 'luxon'; -import { CookieResponse, ImmichCookie } from 'src/dtos/auth.dto'; +import { CookieResponse } from 'src/dtos/auth.dto'; +import { ImmichCookie } from 'src/enum'; export const respondWithCookie = (res: Response, body: T, { isSecure, values }: CookieResponse) => { const defaults: CookieOptions = { diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts new file mode 100644 index 0000000000..027afcf040 --- /dev/null +++ b/server/src/utils/tag.ts @@ -0,0 +1,25 @@ +import { TagEntity } from 'src/entities/tag.entity'; +import { ITagRepository } from 'src/interfaces/tag.interface'; + +type UpsertRequest = { userId: string; tags: string[] }; +export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { + tags = [...new Set(tags)]; + + const results: TagEntity[] = []; + + for (const tag of tags) { + const parts = tag.split('/').filter(Boolean); + let parent: TagEntity | undefined; + + for (const part of parts) { + const value = parent ? `${parent.value}/${part}` : part; + parent = await repository.upsertValue({ userId, value, parent }); + } + + if (parent) { + results.push(parent); + } + } + + return results; +}; diff --git a/server/src/utils/workers.spec.ts b/server/src/utils/workers.spec.ts deleted file mode 100644 index 1e4ff5e2d3..0000000000 --- a/server/src/utils/workers.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getWorkers } from 'src/utils/workers'; - -describe('getWorkers', () => { - beforeEach(() => { - process.env.IMMICH_WORKERS_INCLUDE = ''; - process.env.IMMICH_WORKERS_EXCLUDE = ''; - }); - - it('should return default workers', () => { - expect(getWorkers()).toEqual(['api', 'microservices']); - }); - - it('should return included workers', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should excluded workers from defaults', () => { - process.env.IMMICH_WORKERS_EXCLUDE = 'api'; - expect(getWorkers()).toEqual(['microservices']); - }); - - it('should exclude workers from include list', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; - process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should remove whitespace from included workers before parsing', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices'; - expect(getWorkers()).toEqual(['api', 'microservices']); - }); - - it('should remove whitespace from excluded workers before parsing', () => { - process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices'; - expect(getWorkers()).toEqual([]); - }); - - it('should remove whitespace from included and excluded workers before parsing', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2'; - process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should throw error for invalid workers', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; - expect(getWorkers).toThrowError('Invalid worker(s) found: api,microservices,randomservice'); - }); -}); diff --git a/server/src/utils/workers.ts b/server/src/utils/workers.ts deleted file mode 100644 index 14daa2620f..0000000000 --- a/server/src/utils/workers.ts +++ /dev/null @@ -1,21 +0,0 @@ -const WORKER_TYPES = new Set(['api', 'microservices']); - -export const getWorkers = () => { - let workers = ['api', 'microservices']; - const includedWorkers = process.env.IMMICH_WORKERS_INCLUDE?.replaceAll(/\s/g, ''); - const excludedWorkers = process.env.IMMICH_WORKERS_EXCLUDE?.replaceAll(/\s/g, ''); - - if (includedWorkers) { - workers = includedWorkers.split(','); - } - - if (excludedWorkers) { - workers = workers.filter((worker) => !excludedWorkers.split(',').includes(worker)); - } - - if (workers.some((worker) => !WORKER_TYPES.has(worker))) { - throw new Error(`Invalid worker(s) found: ${workers}`); - } - - return workers; -}; diff --git a/server/src/validation.spec.ts b/server/src/validation.spec.ts index d470918107..7cd7826223 100644 --- a/server/src/validation.spec.ts +++ b/server/src/validation.spec.ts @@ -1,10 +1,11 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { DateTime } from 'luxon'; import { IsDateStringFormat, MaxDateString } from 'src/validation'; describe('Validation', () => { describe('MaxDateString', () => { - const maxDate = new Date(2000, 0, 1); + const maxDate = DateTime.fromISO('2000-01-01', { zone: 'utc' }); class MyDto { @MaxDateString(maxDate) diff --git a/server/src/validation.ts b/server/src/validation.ts index 063f2150a5..180d53ed56 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -21,11 +21,11 @@ import { ValidationOptions, buildMessage, isDateString, - maxDate, } from 'class-validator'; import { CronJob } from 'cron'; import { DateTime } from 'luxon'; import sanitize from 'sanitize-filename'; +import { isIP, isIPRange } from 'validator'; @Injectable() export class ParseMeUUIDPipe extends ParseUUIDPipe { @@ -66,6 +66,8 @@ export class UUIDParamDto { export interface OptionalOptions extends ValidationOptions { nullable?: boolean; + /** convert empty strings to null */ + emptyToNull?: boolean; } /** @@ -76,12 +78,20 @@ export interface OptionalOptions extends ValidationOptions { * @see IsOptional exported from `class-validator. */ // https://stackoverflow.com/a/71353929 -export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { +export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) { + const decorators: PropertyDecorator[] = []; + if (nullable === true) { - return IsOptional(validationOptions); + decorators.push(IsOptional(validationOptions)); + } else { + decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions)); } - return ValidateIf((object: any, v: any) => v !== undefined, validationOptions); + if (emptyToNull) { + decorators.push(Transform(({ value }) => (value === '' ? null : value))); + } + + return applyDecorators(...decorators); } type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; @@ -193,14 +203,21 @@ export function IsDateStringFormat(format: string, validationOptions?: Validatio ); } -export function MaxDateString(date: Date | (() => Date), validationOptions?: ValidationOptions): PropertyDecorator { +function maxDate(date: DateTime, maxDate: DateTime | (() => DateTime)) { + return date <= (maxDate instanceof DateTime ? maxDate : maxDate()); +} + +export function MaxDateString( + date: DateTime | (() => DateTime), + validationOptions?: ValidationOptions, +): PropertyDecorator { return ValidateBy( { name: 'maxDateString', constraints: [date], validator: { validate: (value, args) => { - const date = DateTime.fromISO(value, { zone: 'utc' }).toJSDate(); + const date = DateTime.fromISO(value, { zone: 'utc' }); return maxDate(date, args?.constraints[0]); }, defaultMessage: buildMessage( @@ -212,3 +229,32 @@ export function MaxDateString(date: Date | (() => Date), validationOptions?: Val validationOptions, ); } + +type IsIPRangeOptions = { requireCIDR?: boolean }; +export function IsIPRange(options: IsIPRangeOptions, validationOptions?: ValidationOptions): PropertyDecorator { + const { requireCIDR } = { requireCIDR: true, ...options }; + + return ValidateBy( + { + name: 'isIPRange', + validator: { + validate: (value): boolean => { + if (isIPRange(value)) { + return true; + } + + if (!requireCIDR && isIP(value)) { + return true; + } + + return false; + }, + defaultMessage: buildMessage( + (eachPrefix) => eachPrefix + '$property must be an ip address, or ip address range', + validationOptions, + ), + }, + }, + validationOptions, + ); +} diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 5857f587a0..bc8eb22b20 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -5,46 +5,43 @@ import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; import { ApiModule } from 'src/app.module'; -import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/constants'; +import { excludePaths, serverVersion } from 'src/constants'; +import { ImmichEnvironment } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { ApiService } from 'src/services/api.service'; -import { otelStart } from 'src/utils/instrumentation'; +import { isStartUpError } from 'src/services/storage.service'; import { useSwagger } from 'src/utils/misc'; -const host = process.env.HOST; - -function parseTrustedProxy(input?: string) { - if (!input) { - return []; - } - // Split on ',' char to allow multiple IPs - return input.split(','); -} - async function bootstrap() { process.title = 'immich-api'; - const otelPort = Number.parseInt(process.env.IMMICH_API_METRICS_PORT ?? '8081'); - const trustedProxies = parseTrustedProxy(process.env.IMMICH_TRUSTED_PROXIES ?? ''); - otelStart(otelPort); + const { telemetry, network } = new ConfigRepository().getEnv(); + if (telemetry.metrics.size > 0) { + bootstrapTelemetry(telemetry.apiPort); + } - const port = Number(process.env.IMMICH_PORT) || 3001; const app = await NestFactory.create(ApiModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); + const configRepository = app.get(IConfigRepository); + + const { environment, host, port, resourcePaths } = configRepository.getEnv(); + const isDev = environment === ImmichEnvironment.DEVELOPMENT; - logger.setAppName('Api'); logger.setContext('Bootstrap'); app.useLogger(logger); - app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal', ...trustedProxies]); + app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal', ...network.trustedProxies]); app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); - if (isDev()) { + if (isDev) { app.enableCors(); } app.useWebSocketAdapter(new WebSocketAdapter(app)); - useSwagger(app); + useSwagger(app, { write: isDev }); app.setGlobalPrefix('api', { exclude: excludePaths }); if (existsSync(resourcePaths.web.root)) { @@ -69,10 +66,13 @@ async function bootstrap() { const server = await (host ? app.listen(port, host) : app.listen(port)); server.requestTimeout = 30 * 60 * 1000; - logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); + logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); } bootstrap().catch((error) => { - console.error(error); - throw error; + if (!isStartUpError(error)) { + console.error(error); + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); }); diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index f920e8c947..bd1e65d6cc 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -1,31 +1,38 @@ import { NestFactory } from '@nestjs/core'; import { isMainThread } from 'node:worker_threads'; import { MicroservicesModule } from 'src/app.module'; -import { envName, serverVersion } from 'src/constants'; +import { serverVersion } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; -import { otelStart } from 'src/utils/instrumentation'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; +import { isStartUpError } from 'src/services/storage.service'; export async function bootstrap() { - const otelPort = Number.parseInt(process.env.IMMICH_MICROSERVICES_METRICS_PORT ?? '8082'); - - otelStart(otelPort); + const { telemetry } = new ConfigRepository().getEnv(); + if (telemetry.metrics.size > 0) { + bootstrapTelemetry(telemetry.microservicesPort); + } const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); - logger.setAppName('Microservices'); logger.setContext('Bootstrap'); app.useLogger(logger); app.useWebSocketAdapter(new WebSocketAdapter(app)); await app.listen(0); - logger.log(`Immich Microservices is running [v${serverVersion}] [${envName}] `); + const configRepository = app.get(IConfigRepository); + const { environment } = configRepository.getEnv(); + logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `); } if (!isMainThread) { bootstrap().catch((error) => { - console.error(error); - process.exit(1); + if (!isStartUpError(error)) { + console.error(error); + } + throw error; }); } diff --git a/server/start.sh b/server/start.sh index 5aa7ee01b2..1a08d01a75 100755 --- a/server/start.sh +++ b/server/start.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash +echo "Initializing Immich $IMMICH_SOURCE_REF" + lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" export LD_PRELOAD="$lib_path" +export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" read_file_and_export() { if [ -n "${!1}" ]; then diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 4105b01978..3d2899d3c6 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -1,5 +1,5 @@ -import { AlbumUserRole } from 'src/entities/album-user.entity'; -import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -155,55 +155,4 @@ export const albumStub = { isActivityEnabled: true, order: AssetOrder.DESC, }), - emptyWithInvalidThumbnail: Object.freeze({ - id: 'album-5', - albumName: 'Empty album with invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetInvalidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.livePhotoMotionAsset, - albumThumbnailAssetId: assetStub.livePhotoMotionAsset.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetValidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.image, - albumThumbnailAssetId: assetStub.image.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), }; diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts index 954c8f35a0..f8b1832c84 100644 --- a/server/test/fixtures/api-key.stub.ts +++ b/server/test/fixtures/api-key.stub.ts @@ -11,7 +11,3 @@ export const keyStub = { user: userStub.admin, } as APIKeyEntity), }; - -export const apiKeyCreateStub = { - name: 'API Key', -}; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index aa141a9964..45390cf92e 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,15 +1,37 @@ -import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { AssetFileEntity } from 'src/entities/asset-files.entity'; +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 { 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 = { + id: 'file-1', + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: '/uploads/user-id/thumbs/path.jpg', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const thumbnailFile: AssetFileEntity = { + id: 'file-2', + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: '/uploads/user-id/webp/path.ext', + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), +}; + +const files: AssetFileEntity[] = [previewFile, thumbnailFile]; + export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { return { id: stackId, - assets: assets, + assets, owner: assets[0].owner, ownerId: assets[0].ownerId, primaryAsset: assets[0], @@ -20,6 +42,7 @@ export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity = export const assetStub = { noResizePath: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'IMG_123.jpg', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -28,10 +51,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_123.jpg', - previewPath: null, + files: [thumbnailFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -48,13 +70,14 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, - isOffline: false, isExternal: false, duplicateId: null, + isOffline: false, }), noWebpPath: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -62,10 +85,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_456.jpg', - previewPath: '/uploads/user-id/thumbs/path.ext', + files: [previewFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -82,17 +104,18 @@ export const assetStub = { originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, - isOffline: false, isExternal: false, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), noThumbhash: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -100,10 +123,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -111,7 +133,6 @@ export const assetStub = { localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, - isOffline: false, duration: null, isVisible: true, isExternal: false, @@ -124,10 +145,12 @@ export const assetStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isOffline: false, }), primaryImage: Object.freeze({ id: 'primary-asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -135,10 +158,9 @@ export const assetStub = { ownerId: 'admin-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/admin-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), + files, type: AssetType.IMAGE, - thumbnailPath: '/uploads/admin-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -151,7 +173,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -169,10 +190,12 @@ export const assetStub = { { id: 'stack-child-asset-2' } as AssetEntity, ]), duplicateId: null, + isOffline: false, }), image: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -180,10 +203,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -196,7 +218,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -209,6 +230,7 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), trashed: Object.freeze({ @@ -220,10 +242,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -237,7 +258,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -249,10 +269,13 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, + status: AssetStatus.TRASHED, }), - archived: Object.freeze({ + trashedOffline: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -260,10 +283,48 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: false, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + isOffline: true, + }), + archived: Object.freeze({ + id: 'asset-id', + status: AssetStatus.ACTIVE, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -276,7 +337,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -289,10 +349,12 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), external: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -300,10 +362,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('path hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -316,101 +377,24 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - }), - - offline: Object.freeze({ - id: 'asset-id', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', - thumbhash: Buffer.from('blablabla', 'base64'), - 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'), - isFavorite: true, - isArchived: false, - isExternal: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, - }), - - externalOffline: Object.freeze({ - id: 'asset-id', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', - thumbhash: Buffer.from('blablabla', 'base64'), - 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'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, }), image1: Object.freeze({ id: 'asset-id-1', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -418,10 +402,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -435,7 +418,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', @@ -445,10 +427,12 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageFrom2015: Object.freeze({ id: 'asset-id-1', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'), @@ -456,10 +440,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', + files, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2015-02-23T05:06:29.716Z'), @@ -468,7 +451,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -483,10 +465,12 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), video: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -495,10 +479,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -507,7 +490,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -523,9 +505,11 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), livePhotoMotionAsset: Object.freeze({ + status: AssetStatus.ACTIVE, id: fileStub.livePhotoMotion.uuid, originalPath: fileStub.livePhotoMotion.originalPath, ownerId: authStub.user1.user.id, @@ -539,39 +523,9 @@ export const assetStub = { }, } as AssetEntity), - liveMotionWithThumb: Object.freeze({ - id: fileStub.livePhotoMotion.uuid, - originalPath: fileStub.livePhotoMotion.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.VIDEO, - isVisible: false, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - previewPath: '/uploads/user-id/thumbs/path.ext', - thumbnailPath: '/uploads/user-id/webp/path.ext', - exifInfo: { - fileSizeInByte: 100_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', - originalPath: fileStub.livePhotoStill.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.IMAGE, - livePhotoVideoId: 'live-photo-motion-asset', - isVisible: true, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - exifInfo: { - fileSizeInByte: 25_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - - livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({ - id: 'live-photo-still-asset-1', + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoStill.originalPath, ownerId: authStub.user1.user.id, type: AssetType.IMAGE, @@ -587,6 +541,7 @@ export const assetStub = { livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoStill.originalPath, originalFileName: fileStub.livePhotoStill.originalName, ownerId: authStub.user1.user.id, @@ -603,6 +558,7 @@ export const assetStub = { withLocation: Object.freeze({ id: 'asset-with-favorite-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'), @@ -611,10 +567,9 @@ export const assetStub = { deviceId: 'device-id', checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', sidecarPath: null, type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-22T05:06:29.716Z'), @@ -623,7 +578,6 @@ export const assetStub = { isFavorite: false, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -642,9 +596,12 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), + sidecar: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -652,11 +609,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -664,7 +620,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -676,9 +631,12 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, + isOffline: false, }), + sidecarWithoutExt: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -686,11 +644,10 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: null, + files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -698,7 +655,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -710,45 +666,12 @@ export const assetStub = { sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, - }), - - readOnly: Object.freeze({ - id: 'read-only-asset', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - thumbnailPath: null, - 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'), - isFavorite: true, - isArchived: false, - isExternal: false, isOffline: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - sidecarPath: '/original/path.ext.xmp', - deletedAt: null, - duplicateId: null, }), hasEncodedVideo: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -757,10 +680,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.ext', - previewPath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.VIDEO, - thumbnailPath: null, + files: [previewFile], thumbhash: null, encodedVideoPath: '/encoded/video/path.mp4', createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -769,7 +691,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -783,48 +704,12 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, - }), - missingFileExtension: Object.freeze({ - id: 'asset-id', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', - thumbhash: Buffer.from('blablabla', 'base64'), - 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'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'photo', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, }), + hasFileExtension: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -832,10 +717,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -848,7 +732,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -861,9 +744,12 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), + imageDng: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -871,10 +757,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.dng', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -887,7 +772,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -900,9 +784,12 @@ export const assetStub = { bitsPerSample: 14, } as ExifEntity, duplicateId: null, + isOffline: false, }), + hasEmbedding: Object.freeze({ id: 'asset-id-embedding', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -910,10 +797,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -926,7 +812,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -941,9 +826,12 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), + hasDupe: Object.freeze({ id: 'asset-id-dupe', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -951,10 +839,9 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: '/original/path.jpg', - previewPath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, - thumbnailPath: '/uploads/user-id/webp/path.ext', + files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -967,7 +854,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -982,5 +868,6 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), }; diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts index bca1d33491..24f78a17ce 100644 --- a/server/test/fixtures/audit.stub.ts +++ b/server/test/fixtures/audit.stub.ts @@ -1,23 +1,8 @@ -import { AuditEntity, DatabaseAction, EntityType } from 'src/entities/audit.entity'; +import { AuditEntity } from 'src/entities/audit.entity'; +import { DatabaseAction, EntityType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const auditStub = { - create: Object.freeze({ - id: 1, - entityId: 'asset-created', - action: DatabaseAction.CREATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), - update: Object.freeze({ - id: 2, - entityId: 'asset-updated', - action: DatabaseAction.UPDATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), delete: Object.freeze({ id: 3, entityId: 'asset-deleted', diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index bbb53d4db6..2989c0cce1 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -35,17 +35,6 @@ export const authStub = { id: 'token-id', } as SessionEntity, }), - external1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - session: { - id: 'token-id', - } as SessionEntity, - }), adminSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -76,20 +65,6 @@ export const authStub = { key: Buffer.from('shared-link-key'), } as SharedLinkEntity, }), - readonlySharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - sharedLink: { - id: '123', - allowUpload: false, - allowDownload: false, - showExif: true, - } as SharedLinkEntity, - }), passwordSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -106,35 +81,3 @@ export const authStub = { } as SharedLinkEntity, }), }; - -export const loginResponseStub = { - admin: { - response: { - accessToken: expect.any(String), - name: 'Immich Admin', - isAdmin: true, - profileImagePath: '', - shouldChangePassword: true, - userEmail: 'admin@immich.app', - userId: expect.any(String), - }, - }, - user1oauth: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - user1password: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, -}; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 82935dd345..b8c68d5bf4 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -1,4 +1,5 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { SourceType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { personStub } from 'test/fixtures/person.stub'; @@ -17,6 +18,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] }, }), primaryFace1: Object.freeze>({ @@ -31,6 +33,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] }, }), mergeFace1: Object.freeze>({ @@ -45,22 +48,9 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, }), - mergeFace2: Object.freeze>({ - id: 'assetFaceId4', - assetId: assetStub.image1.id, - asset: assetStub.image1, - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] }, - }), start: Object.freeze>({ id: 'assetFaceId5', assetId: assetStub.image.id, @@ -73,6 +63,7 @@ export const faceStub = { boundingBoxY2: 505, imageHeight: 2880, imageWidth: 2160, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] }, }), middle: Object.freeze>({ @@ -87,6 +78,7 @@ export const faceStub = { boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] }, }), end: Object.freeze>({ @@ -101,6 +93,7 @@ export const faceStub = { boundingBoxY2: 495, imageHeight: 500, imageWidth: 500, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] }, }), noPerson1: Object.freeze({ @@ -115,6 +108,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] }, }), noPerson2: Object.freeze({ @@ -129,6 +123,35 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] }, }), + fromExif1: Object.freeze({ + id: 'assetFaceId9', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: personStub.randomPerson.id, + person: personStub.randomPerson, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, + sourceType: SourceType.EXIF, + }), + fromExif2: Object.freeze({ + id: 'assetFaceId9', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: personStub.randomPerson.id, + person: personStub.randomPerson, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + sourceType: SourceType.EXIF, + }), }; diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index 1a83ffe5d7..bb40035dcc 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -1,6 +1,3 @@ -import { join } from 'node:path'; -import { APP_MEDIA_LOCATION } from 'src/constants'; -import { THUMBNAIL_DIR } from 'src/cores/storage.core'; import { LibraryEntity } from 'src/entities/library.entity'; import { userStub } from 'test/fixtures/user.stub'; @@ -53,18 +50,6 @@ export const libraryStub = { refreshedAt: null, exclusionPatterns: [], }), - externalLibraryWithExclusionPattern: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: [], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), patternPath: Object.freeze({ id: 'library-id1337', name: 'importpath-exclusion-library1', @@ -83,7 +68,7 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], + importPaths: ['upload/thumbs', 'xyz', 'upload/library'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 9b4e15a95d..cdcdfd4d5e 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -154,6 +154,13 @@ export const probeStub = { ...probeStubDefault, audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }], }), + audioStreamUnknown: Object.freeze({ + ...probeStubDefault, + audioStreams: [ + { index: 0, codecName: 'aac', frameCount: 100 }, + { index: 1, codecName: 'unknown', frameCount: 200 }, + ], + }), matroskaContainer: Object.freeze({ ...probeStubDefault, format: { diff --git a/server/test/fixtures/memory.stub.ts b/server/test/fixtures/memory.stub.ts index bb84a8f1df..50872d8ac1 100644 --- a/server/test/fixtures/memory.stub.ts +++ b/server/test/fixtures/memory.stub.ts @@ -1,4 +1,5 @@ -import { MemoryEntity, MemoryType } from 'src/entities/memory.entity'; +import { MemoryEntity } from 'src/entities/memory.entity'; +import { MemoryType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/test/fixtures/metadata.stub.ts b/server/test/fixtures/metadata.stub.ts new file mode 100644 index 0000000000..05535303e4 --- /dev/null +++ b/server/test/fixtures/metadata.stub.ts @@ -0,0 +1,71 @@ +import { ImmichTags } from 'src/interfaces/metadata.interface'; +import { personStub } from 'test/fixtures/person.stub'; + +export const metadataStub = { + empty: Object.freeze({}), + withFace: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Name: personStub.withName.name, + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), + withFaceEmptyName: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Name: '', + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), + withFaceNoName: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), +}; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 3584d0486e..544894b31e 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -44,20 +44,6 @@ export const personStub = { faceAsset: null, isHidden: false, }), - noBirthDate: Object.freeze({ - id: 'person-1', - 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: null, - faceAsset: null, - isHidden: false, - }), withBirthDate: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 4d661bc571..e446a6180b 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -3,10 +3,9 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; -import { AssetOrder } from 'src/entities/album.entity'; -import { AssetType } from 'src/entities/asset.entity'; -import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entity'; +import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -55,7 +54,6 @@ const assetResponse: AssetResponseDto = { originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', - resized: false, thumbhash: null, fileModifiedAt: today, isOffline: false, @@ -77,14 +75,12 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, - stackCount: 0, }; const assetResponseWithoutMetadata = { id: 'id_1', type: AssetType.VIDEO, originalMimeType: 'image/jpeg', - resized: false, thumbhash: null, localDateTime: today, duration: '0:00:00.00000', @@ -192,13 +188,13 @@ export const sharedLinkStub = { assets: [ { id: 'id_1', + status: AssetStatus.ACTIVE, owner: undefined as unknown as UserEntity, ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', deviceId: 'device_id_1', type: AssetType.VIDEO, originalPath: 'fake_path/jpeg', - previewPath: '', checksum: Buffer.from('file hash', 'utf8'), fileModifiedAt: today, fileCreatedAt: today, @@ -215,7 +211,7 @@ export const sharedLinkStub = { objects: ['a', 'b', 'c'], asset: null as any, }, - thumbnailPath: '', + files: [], thumbhash: null, encodedVideoPath: '', duration: null, @@ -253,6 +249,7 @@ export const sharedLinkStub = { bitsPerSample: 8, colorspace: 'sRGB', autoStackId: null, + rating: 3, }, tags: [], sharedLinks: [], @@ -312,21 +309,6 @@ export const sharedLinkResponseStub = { type: SharedLinkType.ALBUM, userId: 'admin_id', }), - readonly: Object.freeze({ - id: '123', - userId: 'admin_id', - key: sharedLinkBytes.toString('base64url'), - type: SharedLinkType.ALBUM, - createdAt: today, - expiresAt: tomorrow, - description: null, - password: null, - allowUpload: false, - allowDownload: false, - showMetadata: true, - album: albumResponse, - assets: [assetResponse], - }), readonlyNoMetadata: Object.freeze({ id: '123', userId: 'admin_id', diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index be21fc4060..9c6822d52f 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -74,6 +74,15 @@ export const systemConfigStub = { }, }, }, + backupEnabled: { + backup: { + database: { + enabled: true, + cronExpression: '0 0 * * *', + keepLastAmount: 1, + }, + }, + }, machineLearningDisabled: { machineLearning: { enabled: false, diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 537c65db47..b245bfe9e5 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,24 +1,65 @@ import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { userStub } from 'test/fixtures/user.stub'; +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, +}); + +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, +}); + export const tagStub = { tag1: Object.freeze({ id: 'tag-1', - name: 'Tag1', - type: TagType.CUSTOM, + 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, - renameTagId: null, - assets: [], }), }; export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), name: 'Tag1', - type: 'CUSTOM', - userId: 'admin_id', + value: 'Tag1', + }), + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + color: '#000000', + name: 'Tag1', + value: 'Tag1', }), }; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index cb82dfe26c..9553b5344a 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,36 +1,13 @@ -import { UserAvatarColor, UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; -export const userDto = { - user1: { - email: 'user1@immich.app', - password: 'Password123', - name: 'User 1', - }, - user2: { - email: 'user2@immich.app', - password: 'Password123', - name: 'User 2', - }, - user3: { - email: 'user3@immich.app', - password: 'Password123', - name: 'User 3', - }, - userWithQuota: { - email: 'quota-user@immich.app', - password: 'Password123', - name: 'User with quota', - quotaSizeInBytes: 42, - }, -}; - export const userStub = { admin: Object.freeze({ ...authStub.admin.user, password: 'admin_password', name: 'admin_name', + id: 'admin_id', storageLabel: 'admin', oauthId: '', shouldChangePassword: false, @@ -100,22 +77,6 @@ export const userStub = { quotaSizeInBytes: null, quotaUsageInBytes: 0, }), - externalPathRoot: Object.freeze({ - ...authStub.user1.user, - password: 'immich_password', - name: 'immich_name', - storageLabel: 'label-1', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }), profilePath: Object.freeze({ ...authStub.user1.user, password: 'immich_password', diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts new file mode 100644 index 0000000000..3ccce0f16e --- /dev/null +++ b/server/test/medium/metadata.service.spec.ts @@ -0,0 +1,137 @@ +import { Stats } from 'node:fs'; +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 { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MetadataService } from 'src/services/metadata.service'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newRandomImage, newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; + +const metadataRepository = new MetadataRepository(newLoggerRepositoryMock()); + +const createTestFile = async (exifData: Record) => { + const data = newRandomImage(); + const filePath = join(tmpdir(), 'test.png'); + await writeFile(filePath, data); + await metadataRepository.writeTags(filePath, exifData); + return { filePath }; +}; + +type TimeZoneTest = { + description: string; + serverTimeZone?: string; + exifData: Record; + expected: { + localDateTime: string; + dateTimeOriginal: string; + timeZone: string | null; + }; +}; + +describe(MetadataService.name, () => { + let sut: MetadataService; + + let assetMock: Mocked; + let storageMock: Mocked; + + beforeEach(() => { + ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + + storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + + delete process.env.TZ; + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleMetadataExtraction', () => { + const timeZoneTests: TimeZoneTest[] = [ + { + description: 'should handle no time zone information', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T00:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server behind UTC', + serverTimeZone: 'America/Los_Angeles', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T08:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T23:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC in the summer', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:06:01 00:00:00', + }, + expected: { + localDateTime: '2022-06-01T00:00:00.000Z', + dateTimeOriginal: '2022-05-31T22:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle a +13:00 time zone', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00+13:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T11:00:00.000Z', + timeZone: 'UTC+13', + }, + }, + ]; + + it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => { + process.env.TZ = serverTimeZone ?? undefined; + + const { filePath } = await createTestFile(exifData); + assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + + await sut.handleMetadataExtraction({ id: 'asset-1' }); + + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + dateTimeOriginal: new Date(expected.dateTimeOriginal), + timeZone: expected.timeZone, + }), + ); + + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: new Date(expected.localDateTime), + }), + ); + }); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 8d69e35c05..9e9bf5406b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,4 +1,3 @@ -import { AccessCore } from 'src/cores/access.core'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Mocked, vitest } from 'vitest'; @@ -7,17 +6,15 @@ export interface IAccessRepositoryMock { asset: Mocked; album: Mocked; authDevice: Mocked; - timeline: Mocked; memory: Mocked; person: Mocked; partner: Mocked; + stack: Mocked; + timeline: Mocked; + tag: Mocked; } -export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { - if (reset) { - AccessCore.reset(); - } - +export const newAccessRepositoryMock = (): IAccessRepositoryMock => { return { activity: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), @@ -42,10 +39,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, - timeline: { - checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), - }, - memory: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, @@ -58,5 +51,17 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => partner: { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + + stack: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + timeline: { + checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + tag: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index af267dd49c..dd5c3af6a8 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -4,17 +4,14 @@ import { Mocked, vitest } from 'vitest'; export const newAlbumRepositoryMock = (): Mocked => { return { getById: vitest.fn(), - getByIds: vitest.fn(), getByAssetId: vitest.fn(), getMetadataForIds: vitest.fn(), - getInvalidThumbnail: vitest.fn(), getOwned: vitest.fn(), getShared: vitest.fn(), getNotShared: vitest.fn(), restoreAll: vitest.fn(), softDeleteAll: vitest.fn(), deleteAll: vitest.fn(), - getAll: vitest.fn(), addAssetIds: vitest.fn(), removeAsset: vitest.fn(), removeAssetIds: vitest.fn(), diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index f1091c041f..982273ff69 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -17,16 +17,13 @@ export const newAssetRepositoryMock = (): Mocked => { getByChecksum: vitest.fn(), getByChecksums: vitest.fn(), getUploadAssetIdByChecksum: vitest.fn(), - getWith: vitest.fn(), getRandom: vitest.fn(), - getFirstAssetForAlbumId: vitest.fn(), getLastUpdatedAssetForAlbumId: vitest.fn(), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: vitest.fn(), getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), - getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), update: vitest.fn(), @@ -35,12 +32,12 @@ export const newAssetRepositoryMock = (): Mocked => { getStatistics: vitest.fn(), getTimeBucket: vitest.fn(), getTimeBuckets: vitest.fn(), - restoreAll: vitest.fn(), - softDeleteAll: vitest.fn(), getAssetIdByCity: vitest.fn(), getAssetIdByTag: vitest.fn(), getAllForUserFullSync: vitest.fn(), getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), + upsertFile: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts new file mode 100644 index 0000000000..55a890fa4a --- /dev/null +++ b/server/test/repositories/config.repository.mock.ts @@ -0,0 +1,100 @@ +import { ImmichEnvironment, ImmichWorker } from 'src/enum'; +import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { Mocked, vitest } from 'vitest'; + +const envData: EnvData = { + port: 2283, + environment: ImmichEnvironment.PRODUCTION, + + buildMetadata: {}, + bull: { + config: { + prefix: 'immich_bull', + }, + queues: [{ name: 'queue-1' }], + }, + + cls: { + config: {}, + }, + + database: { + config: { + connectionType: 'parts', + database: 'immich', + type: 'postgres', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + synchronize: false, + migrationsRun: true, + }, + + skipMigrations: false, + vectorExtension: DatabaseExtension.VECTORS, + }, + + licensePublicKey: { + client: 'client-public-key', + server: 'server-public-key', + }, + + network: { + trustedProxies: [], + }, + + otel: { + metrics: { + hostMetrics: false, + apiMetrics: { + enable: false, + ignoreRoutes: [], + }, + }, + }, + + redis: { + host: 'redis', + port: 6379, + db: 0, + }, + + resourcePaths: { + lockFile: 'build-lock.json', + geodata: { + dateFile: '/build/geodata/geodata-date.txt', + admin1: '/build/geodata/admin1CodesASCII.txt', + admin2: '/build/geodata/admin2Codes.txt', + cities500: '/build/geodata/cities500.txt', + naturalEarthCountriesPath: 'build/ne_10m_admin_0_countries.geojson', + }, + web: { + root: '/build/www', + indexHtml: '/build/www/index.html', + }, + }, + + storage: { + ignoreMountCheckErrors: false, + }, + + telemetry: { + apiPort: 8081, + microservicesPort: 8082, + metrics: new Set(), + }, + + workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES], + + noColor: false, +}; + +export const mockEnvData = (config: Partial) => ({ ...envData, ...config }); +export const newConfigRepositoryMock = (): Mocked => { + return { + getEnv: vitest.fn().mockReturnValue(mockEnvData({})), + }; +}; diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index aef2e50ae8..da6417a38c 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -3,11 +3,12 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked => { return { + reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), - getAvailableExtensionVersion: vitest.fn(), + getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), + getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'), createExtension: vitest.fn().mockResolvedValue(void 0), - updateExtension: vitest.fn(), updateVectorExtension: vitest.fn(), reindex: vitest.fn(), shouldReindex: vitest.fn(), diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599..a425ddef3a 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,10 +3,10 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + setup: vitest.fn(), emit: vitest.fn() as any, - clientSend: vitest.fn(), - clientBroadcast: vitest.fn(), + 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 6bffe184fd..cfa1826dd8 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -5,7 +5,6 @@ export const newJobRepositoryMock = (): Mocked => { return { addHandler: vitest.fn(), addCronJob: vitest.fn(), - deleteCronJob: vitest.fn(), updateCronJob: vitest.fn(), setConcurrency: vitest.fn(), empty: vitest.fn(), @@ -17,5 +16,6 @@ export const newJobRepositoryMock = (): Mocked => { getJobCounts: vitest.fn(), clear: vitest.fn(), waitForQueueCompletion: vitest.fn(), + removeJob: vitest.fn(), }; }; diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts index e5b8e5c763..83e97c7ffa 100644 --- a/server/test/repositories/library.repository.mock.ts +++ b/server/test/repositories/library.repository.mock.ts @@ -9,7 +9,6 @@ export const newLibraryRepositoryMock = (): Mocked => { softDelete: vitest.fn(), update: vitest.fn(), getStatistics: vitest.fn(), - getAssetIds: 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 index 5f7262c7e5..6342e9e73c 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts index 95965522e3..703e8696f1 100644 --- a/server/test/repositories/map.repository.mock.ts +++ b/server/test/repositories/map.repository.mock.ts @@ -6,6 +6,5 @@ export const newMapRepositoryMock = (): Mocked => { init: vitest.fn(), reverseGeocode: vitest.fn(), getMapMarkers: vitest.fn(), - fetchStyle: vitest.fn(), }; }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a9866..a809b08162 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 5dbfb3d453..60c5644b36 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -7,10 +7,5 @@ export const newMetadataRepositoryMock = (): Mocked => { readTags: vitest.fn(), writeTags: vitest.fn(), extractBinaryTag: vitest.fn(), - getCameraMakes: vitest.fn(), - getCameraModels: vitest.fn(), - getCities: vitest.fn(), - getCountries: vitest.fn(), - getStates: vitest.fn(), }; }; diff --git a/server/test/repositories/metric.repository.mock.ts b/server/test/repositories/metric.repository.mock.ts deleted file mode 100644 index e2c3e2aac1..0000000000 --- a/server/test/repositories/metric.repository.mock.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newMetricRepositoryMock = (): Mocked => { - return { - api: { - addToCounter: vitest.fn(), - addToGauge: vitest.fn(), - addToHistogram: vitest.fn(), - configure: vitest.fn(), - }, - host: { - addToCounter: vitest.fn(), - addToGauge: vitest.fn(), - addToHistogram: vitest.fn(), - configure: vitest.fn(), - }, - jobs: { - addToCounter: vitest.fn(), - addToGauge: vitest.fn(), - addToHistogram: vitest.fn(), - configure: vitest.fn(), - }, - repo: { - addToCounter: vitest.fn(), - addToGauge: vitest.fn(), - addToHistogram: vitest.fn(), - configure: vitest.fn(), - }, - }; -}; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 71975b429c..16862dc3d7 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked } from 'vitest'; export const newNotificationRepositoryMock = (): Mocked => { return { renderEmail: vitest.fn(), - sendEmail: 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 new file mode 100644 index 0000000000..f87b3781e9 --- /dev/null +++ b/server/test/repositories/oauth.repository.mock.ts @@ -0,0 +1,11 @@ +import { IOAuthRepository } from 'src/interfaces/oauth.interface'; +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 index e16bb6ffdc..ec1f141075 100644 --- a/server/test/repositories/partner.repository.mock.ts +++ b/server/test/repositories/partner.repository.mock.ts @@ -5,7 +5,7 @@ export const newPartnerRepositoryMock = (): Mocked => { return { create: vitest.fn(), remove: vitest.fn(), - getAll: 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 index 94a4486c81..d7b92d3eab 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -6,16 +6,17 @@ export const newPersonRepositoryMock = (): Mocked => { getById: vitest.fn(), getAll: vitest.fn(), getAllForUser: vitest.fn(), - getAssets: vitest.fn(), getAllWithoutFaces: vitest.fn(), getByName: vitest.fn(), + getDistinctNames: vitest.fn(), create: vitest.fn(), + createAll: vitest.fn(), update: vitest.fn(), - deleteAll: vitest.fn(), + updateAll: vitest.fn(), delete: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -23,7 +24,8 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), - createFaces: vitest.fn(), + unassignFaces: vitest.fn(), + refreshFaces: vitest.fn(), getFaces: vitest.fn(), reassignFace: vitest.fn(), getFaceById: vitest.fn(), diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts new file mode 100644 index 0000000000..9a3c5a30b6 --- /dev/null +++ b/server/test/repositories/process.repository.mock.ts @@ -0,0 +1,8 @@ +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 index 7da93e02af..be0e753e30 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -3,14 +3,21 @@ import { Mocked, vitest } from 'vitest'; export const newSearchRepositoryMock = (): Mocked => { return { - init: vitest.fn(), 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/stack.repository.mock.ts b/server/test/repositories/stack.repository.mock.ts index 5567d2e1ac..35d1614de7 100644 --- a/server/test/repositories/stack.repository.mock.ts +++ b/server/test/repositories/stack.repository.mock.ts @@ -3,9 +3,11 @@ 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 615fd5d8c9..0af16a8d17 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -48,7 +48,10 @@ export const newStorageRepositoryMock = (reset = true): Mocked Promise.resolve(filepath)), stat: vitest.fn(), crawl: vitest.fn(), walk: vitest.fn().mockImplementation(async function* () {}), diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index e44301fb21..793dd4c1c0 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,12 +1,9 @@ -import { SystemConfigCore } from 'src/cores/system-config.core'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { clearConfigCache } from 'src/utils/config'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - +export const newSystemMetadataRepositoryMock = (): Mocked => { + clearConfigCache(); return { get: vitest.fn() as any, set: vitest.fn(), diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a5123e0f36..acc2b59f6d 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -4,14 +4,19 @@ import { Mocked, vitest } from 'vitest'; export const newTagRepositoryMock = (): Mocked => { return { getAll: vitest.fn(), - getById: vitest.fn(), + getByValue: vitest.fn(), + upsertValue: vitest.fn(), + upsertAssetTags: vitest.fn(), + + get: vitest.fn(), create: vitest.fn(), update: vitest.fn(), - remove: vitest.fn(), - hasAsset: vitest.fn(), - hasName: vitest.fn(), - getAssets: vitest.fn(), - addAssets: vitest.fn(), - removeAssets: 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 new file mode 100644 index 0000000000..2d537e888a --- /dev/null +++ b/server/test/repositories/telemetry.repository.mock.ts @@ -0,0 +1,21 @@ +import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; +import { Mocked, vitest } from 'vitest'; + +const newMetricGroupMock = () => { + return { + addToCounter: vitest.fn(), + addToGauge: vitest.fn(), + addToHistogram: vitest.fn(), + configure: vitest.fn(), + }; +}; + +export const newTelemetryRepositoryMock = (): Mocked => { + return { + setup: vitest.fn(), + api: newMetricGroupMock(), + host: newMetricGroupMock(), + jobs: newMetricGroupMock(), + repo: newMetricGroupMock(), + }; +}; diff --git a/server/test/repositories/trash.repository.mock.ts b/server/test/repositories/trash.repository.mock.ts new file mode 100644 index 0000000000..472b315b01 --- /dev/null +++ b/server/test/repositories/trash.repository.mock.ts @@ -0,0 +1,11 @@ +import { ITrashRepository } from 'src/interfaces/trash.interface'; +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 index 6071ae47fa..6362ab6a99 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,12 +1,7 @@ -import { UserCore } from 'src/cores/user.core'; import { IUserRepository } from 'src/interfaces/user.interface'; import { Mocked, vitest } from 'vitest'; -export const newUserRepositoryMock = (reset = true): Mocked => { - if (reset) { - UserCore.reset(); - } - +export const newUserRepositoryMock = (): Mocked => { return { get: vitest.fn(), getAdmin: vitest.fn(), diff --git a/server/test/repositories/version-history.repository.mock.ts b/server/test/repositories/version-history.repository.mock.ts new file mode 100644 index 0000000000..7c35e316d3 --- /dev/null +++ b/server/test/repositories/version-history.repository.mock.ts @@ -0,0 +1,10 @@ +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +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 new file mode 100644 index 0000000000..a002362ae7 --- /dev/null +++ b/server/test/repositories/view.repository.mock.ts @@ -0,0 +1,9 @@ +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newViewRepositoryMock = (): Mocked => { + return { + getAssetsByOriginalPath: vitest.fn(), + getUniqueOriginalPaths: vitest.fn(), + }; +}; diff --git a/server/test/utils.ts b/server/test/utils.ts new file mode 100644 index 0000000000..09c86d1afb --- /dev/null +++ b/server/test/utils.ts @@ -0,0 +1,246 @@ +import { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { Writable } from 'node:stream'; +import { PNG } from 'pngjs'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { BaseService } from 'src/services/base.service'; +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 { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; +import { newConfigRepositoryMock } from 'test/repositories/config.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 { newLoggerRepositoryMock } 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 { Readable } from 'typeorm/platform/PlatformTools'; +import { Mocked, vitest } from 'vitest'; + +type RepositoryOverrides = { + metadataRepository: IMetadataRepository; +}; +type BaseServiceArgs = ConstructorParameters; +type Constructor> = { + new (...deps: Args): Type; +}; + +export const newTestService = ( + Service: Constructor, + overrides?: RepositoryOverrides, +) => { + const { metadataRepository } = overrides || {}; + + const accessMock = newAccessRepositoryMock(); + const loggerMock = newLoggerRepositoryMock(); + 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 sut = new Service( + loggerMock, + accessMock, + activityMock, + auditMock, + albumMock, + albumUserMock, + assetMock, + configMock, + cryptoMock, + 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, + ); + + return { + sut, + accessMock, + loggerMock, + 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, + }; +}; + +const createPNG = (r: number, g: number, b: number) => { + const image = new PNG({ width: 1, height: 1 }); + image.data[0] = r; + image.data[1] = g; + image.data[2] = b; + image.data[3] = 255; + return PNG.sync.write(image); +}; + +function* newPngFactory() { + for (let r = 0; r < 255; r++) { + for (let g = 0; g < 255; g++) { + for (let b = 0; b < 255; b++) { + yield createPNG(r, g, b); + } + } + } +} + +const pngFactory = newPngFactory(); + +export const newRandomImage = () => { + const { value } = pngFactory.next(); + if (!value) { + throw new Error('Ran out of random asset data'); + } + + return value; +}; + +export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => { + return { + stdout: new Readable({ + read() { + this.push(stdout); // write mock data to stdout + this.push(null); // end stream + }, + }), + stderr: new Readable({ + read() { + this.push(stderr); // write mock data to stderr + this.push(null); // end stream + }, + }), + stdin: new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }), + exitCode, + on: vitest.fn((event, callback: any) => { + if (event === 'close') { + callback(0); + } + if (event === 'error' && error) { + callback(error); + } + if (event === 'exit') { + callback(exitCode); + } + }), + } as unknown as ChildProcessWithoutNullStreams; +}); diff --git a/server/vitest.config.medium.mjs b/server/vitest.config.medium.mjs new file mode 100644 index 0000000000..40dad8d6a5 --- /dev/null +++ b/server/vitest.config.medium.mjs @@ -0,0 +1,17 @@ +import swc from 'unplugin-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './', + globals: true, + include: ['test/medium/**/*.spec.ts'], + server: { + deps: { + fallbackCJS: true, + }, + }, + }, + plugins: [swc.vite(), tsconfigPaths()], +}); diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 8811dafaf8..92fc027d40 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -6,6 +6,23 @@ export default defineConfig({ test: { root: './', globals: true, + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'], + exclude: [ + 'src/services/*.spec.ts', + 'src/services/api.service.ts', + 'src/services/microservices.service.ts', + 'src/services/index.ts', + ], + thresholds: { + lines: 85, + statements: 85, + branches: 90, + functions: 85, + }, + }, server: { deps: { fallbackCJS: true, diff --git a/web/.eslintignore b/web/.eslintignore deleted file mode 100644 index f944e33c4e..0000000000 --- a/web/.eslintignore +++ /dev/null @@ -1,14 +0,0 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock -svelte.config.js diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs deleted file mode 100644 index de0a64bd37..0000000000 --- a/web/.eslintrc.cjs +++ /dev/null @@ -1,61 +0,0 @@ -/** @type {import('eslint').Linter.Config} */ -module.exports = { - root: true, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:svelte/recommended', - 'plugin:unicorn/recommended', - ], - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], - parserOptions: { - sourceType: 'module', - ecmaVersion: 2022, - extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, - env: { - browser: true, - es2017: true, - node: true, - }, - overrides: [ - { - files: ['*.svelte'], - parser: 'svelte-eslint-parser', - parserOptions: { - parser: '@typescript-eslint/parser', - }, - }, - ], - globals: { - NodeJS: true, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - // Allow underscore (_) variables - argsIgnorePattern: '^_$', - varsIgnorePattern: '^_$', - }, - ], - curly: 2, - 'unicorn/no-useless-undefined': 'off', - 'unicorn/prefer-spread': 'off', - 'unicorn/no-null': 'off', - 'unicorn/prevent-abbreviations': 'off', - 'unicorn/no-nested-ternary': 'off', - 'unicorn/consistent-function-scoping': 'off', - 'unicorn/prefer-top-level-await': 'off', - 'unicorn/import-style': 'off', - 'svelte/button-has-type': 'error', - // TODO: set recommended-type-checked and remove these rules - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-misused-promises': 'error', - '@typescript-eslint/require-await': 'error', - }, -}; diff --git a/web/.nvmrc b/web/.nvmrc index 8ce7030825..7af24b7ddb 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/web/Dockerfile b/web/Dockerfile index 5e1dd28020..674fafcda9 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.16.0-alpine3.20@sha256:eb8101caae9ac02229bd64c024919fe3d4504ff7f329da79ca60a04db08cef52 +FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff RUN apk add --no-cache tini USER node diff --git a/web/README.md b/web/README.md index e9693ceb01..603c7ad64e 100644 --- a/web/README.md +++ b/web/README.md @@ -2,4 +2,4 @@ This project uses the [SvelteKit](https://kit.svelte.dev/) web framework. Please refer to [the SvelteKit docs](https://kit.svelte.dev/docs) for information on getting started as a contributor to this project. In particular, it will help you navigate the project's code if you understand the basics of [SvelteKit routing](https://kit.svelte.dev/docs/routing). -When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [../server](the server project). +When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [the server project](../server). diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000000..f1ba46355f --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,106 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import parser from 'svelte-eslint-parser'; + +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 [ + { + ignores: [ + '**/.DS_Store', + '**/node_modules', + 'build', + '.svelte-kit', + 'package', + '**/.env', + '**/.env.*', + '!**/.env.example', + '**/pnpm-lock.yaml', + '**/package-lock.json', + '**/yarn.lock', + '**/svelte.config.js', + 'eslint.config.mjs', + 'postcss.config.cjs', + 'tailwind.config.js', + ], + }, + ...compat.extends( + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:svelte/recommended', + 'plugin:unicorn/recommended', + ), + { + plugins: { + '@typescript-eslint': typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + NodeJS: true, + }, + + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'module', + + parserOptions: { + extraFileExtensions: ['.svelte'], + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + }, + + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_$', + varsIgnorePattern: '^_$', + }, + ], + + curly: 2, + 'unicorn/no-useless-undefined': 'off', + 'unicorn/prefer-spread': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-nested-ternary': 'off', + 'unicorn/consistent-function-scoping': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'unicorn/import-style': 'off', + 'svelte/button-has-type': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/require-await': 'error', + 'object-shorthand': ['error', 'always'], + }, + }, + { + files: ['**/*.svelte'], + + languageOptions: { + parser: parser, + ecmaVersion: 5, + sourceType: 'script', + + parserOptions: { + parser: '@typescript-eslint/parser', + }, + }, + }, +]; diff --git a/web/package-lock.json b/web/package-lock.json index 72aa494396..5185a235db 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,22 +1,22 @@ { "name": "immich-web", - "version": "1.111.0", + "version": "1.119.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.111.0", + "version": "1.119.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", "@photo-sphere-viewer/video-plugin": "^5.7.2", - "@zoom-image/svelte": "^0.2.6", - "copy-image-clipboard": "^2.1.2", + "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.5.14", @@ -24,18 +24,21 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "devDependencies": { - "@faker-js/faker": "^8.4.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", - "@sveltejs/kit": "^2.5.2", - "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.2", "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.5.2", @@ -43,40 +46,41 @@ "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@typescript-eslint/eslint-plugin": "^7.1.0", - "@typescript-eslint/parser": "^7.1.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", "autoprefixer": "^10.4.17", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.35.1", + "eslint-plugin-svelte": "^2.43.0", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", + "globals": "^15.9.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-sort-json": "^4.0.0", - "prettier-plugin-svelte": "^3.2.1", + "prettier-plugin-svelte": "^3.2.6", "rollup-plugin-visualizer": "^5.12.0", - "svelte": "^4.2.12", - "svelte-check": "^3.6.5", + "svelte": "^4.2.19", + "svelte-check": "^4.0.0", "tailwindcss": "^3.4.1", "tslib": "^2.6.2", - "typescript": "^5.3.3", + "typescript": "^5.5.0", "vite": "^5.1.4", "vitest": "^2.0.5" } }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.111.0", + "version": "1.119.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.14.12", + "@types/node": "^22.8.0", "typescript": "^5.3.3" } }, @@ -133,187 +137,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz", - "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.0", - "@babel/types": "^7.23.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.24.8", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", @@ -332,33 +155,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.23.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.1.tgz", - "integrity": "sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.0", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/highlight": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", @@ -400,45 +196,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz", - "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.25.2", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", @@ -876,24 +633,51 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -901,40 +685,93 @@ "strip-json-comments": "^3.1.1" }, "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/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "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.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", + "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.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": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.3.tgz", + "integrity": "sha512-lWrrK4QNlFSU+13PL9jMbMKLJYXDFu3tQfayBsMXX7KL/GiQeqfB1CzHkqD5UHBUtPAuPo6XwGbMFNdVMZObRA==", "dev": true, "funding": [ { @@ -942,67 +779,84 @@ "url": "https://opencollective.com/fakerjs" } ], + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz", - "integrity": "sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.1.tgz", + "integrity": "sha512-O4ywpkdJybrjFc9zyL8qK5aklleIAi5O4nYhBVJaOFtCkNrnU+lKFeJOFC48zpsZQmR8Aok2V79hGpHnzbmFpg==", + "license": "MIT", "dependencies": { - "@formatjs/intl-localematcher": "0.5.4", - "tslib": "^2.4.0" + "@formatjs/fast-memoize": "2.2.2", + "@formatjs/intl-localematcher": "0.5.6", + "tslib": "2" } }, "node_modules/@formatjs/fast-memoize": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz", - "integrity": "sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.2.tgz", + "integrity": "sha512-mzxZcS0g1pOzwZTslJOBTmLzDXseMLLvnh25ymRilCm8QLMObsQ7x/rj9GNrH0iUhZMlFisVOD6J1n6WQqpKPQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "tslib": "2" } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.7.8", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz", - "integrity": "sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.1.tgz", + "integrity": "sha512-7AYk4tjnLi5wBkxst2w7qFj38JLMJoqzj7BhdEl7oTlsWMlqwgx4p9oMmmvpXWTSDGNwOKBRc1SfwMh5MOHeNg==", + "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.0.0", - "@formatjs/icu-skeleton-parser": "1.8.2", - "tslib": "^2.4.0" + "@formatjs/ecma402-abstract": "2.2.1", + "@formatjs/icu-skeleton-parser": "1.8.5", + "tslib": "2" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz", - "integrity": "sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.5.tgz", + "integrity": "sha512-zRZ/e3B5qY2+JCLs7puTzWS1Jb+t/K+8Jur/gEZpA2EjWeLDE17nsx8thyo9P48Mno7UmafnPupV2NCJXX17Dg==", + "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.0.0", - "tslib": "^2.4.0" + "@formatjs/ecma402-abstract": "2.2.1", + "tslib": "2" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz", - "integrity": "sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.6.tgz", + "integrity": "sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA==", + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "tslib": "2" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -1018,11 +872,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.3", @@ -1613,9 +1475,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1638,6 +1501,13 @@ "geojson-rewind": "geojson-rewind" } }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC", + "peer": true + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1646,6 +1516,25 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/mapbox-gl-rtl-text": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz", + "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==", + "license": "BSD-2-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peer": true, + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -1704,6 +1593,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@namnode/store/-/store-0.1.0.tgz", "integrity": "sha512-4NGTldxKcmY0UuZ7OEkvCjs8ZEoeYB6M2UwMu74pdLiFMKxXbj9HdNk1Qn213bxX1O7bY5h+PLh5DZsTURZkYA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/willnguyen1312" @@ -1745,30 +1635,30 @@ } }, "node_modules/@photo-sphere-viewer/core": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.8.3.tgz", - "integrity": "sha512-Aj2NJic2MM+Ei35+KPFOHTg4F7qjPZfjQgm0xrveso2huearW2cYJaFzEO7d9rwgO6vL6XINVNJHU7710ShepQ==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.11.0.tgz", + "integrity": "sha512-SjEVBBMNj5v3aSLglpTd88q+cyDw0Lke7mK3kN3p+KsQK8X0OG7GsbtI7sGj81zSCbHmV3c0DgUaXMv+xdGFaw==", "license": "MIT", "dependencies": { - "three": "^0.166.1" + "three": "^0.169.0" } }, "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.8.3.tgz", - "integrity": "sha512-3QA3qFwrCtq3ngFAxiQeOZXO9UDoWK6ETYJsdbzl+cM91+3ApQBy2MNq+BasPECpppuYYeVyUscm/CIDj4horg==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.11.0.tgz", + "integrity": "sha512-xs5+vT5jYjBtEsguuJe6CVJNGxtwopb3+Zs4z/VJg0oNm2mTZF2TQu2RkSlRJZTE6a/IfZ7Zv1fJcN3Yiv7AVg==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.8.3" + "@photo-sphere-viewer/core": "5.11.0" } }, "node_modules/@photo-sphere-viewer/video-plugin": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.8.3.tgz", - "integrity": "sha512-vs+zh2UQvOP7xMLGBWw4iIgCmC2lXQEcKqan9BteA/vQalcWWtHa4L6qQCgAt+h+rP6s4TMpTS5ZOfVIfeL3gw==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.11.0.tgz", + "integrity": "sha512-6ApAKvwDRgVZOk4N/3SJ14IFpQ6V0QDDtknXxLI5JqU1yAvBcyb7goa5XDbyTWXYUaPKH06Fz+8UruWRzCsBXw==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.8.3" + "@photo-sphere-viewer/core": "5.11.0" } }, "node_modules/@pkgjs/parseargs": { @@ -1816,169 +1706,224 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz", + "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz", + "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz", + "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz", + "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz", + "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz", + "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz", + "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz", + "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz", + "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz", + "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz", + "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz", + "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz", + "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz", + "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz", + "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz", + "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1990,9 +1935,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.2.tgz", - "integrity": "sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.6.tgz", + "integrity": "sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2000,14 +1945,14 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.1.tgz", - "integrity": "sha512-75A4YiXQp+GRc54EyiNOlhHnHt9O8e0CdCHLm3RWESLRaazd5OIciSa4SbKIo9DM84yGwSVShU0buyUmNJvgWg==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.10.tgz", + "integrity": "sha512-1JxjthN6yb1md3rSFbHRDBi/Jj2R9EjE06vh9zbWgYxm5d4UUphTzNICJTis8bkIDQilbAokrkaQtfRpKSE7qg==", "dev": true, "license": "MIT", "dependencies": { "magic-string": "^0.30.5", - "svelte-parse-markup": "^0.1.2", + "svelte-parse-markup": "^0.1.5", "vite-imagetools": "^7.0.1" }, "peerDependencies": { @@ -2016,16 +1961,16 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.18", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz", - "integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.7.3.tgz", + "integrity": "sha512-Vx7nq5MJ86I8qXYsVidC5PX6xm+uxt8DydvOdmJoyOK7LvGP18OFEG359yY+aa51t6pENvqZAMqAREQQx1OI2Q==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^5.0.0", + "devalue": "^5.1.0", "esm-env": "^1.0.0", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", @@ -2033,7 +1978,7 @@ "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", + "sirv": "^3.0.0", "tiny-glob": "^0.2.9" }, "bin": { @@ -2043,15 +1988,15 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz", - "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", "dependencies": { @@ -2180,14 +2125,13 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.8", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", - "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz", + "integrity": "sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==", "dev": true, "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", - "@babel/runtime": "^7.9.2", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", @@ -2282,9 +2226,9 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.1.tgz", - "integrity": "sha512-yXSqBsYaQAeP2xt7gqKu135Q67+NTsBDcpL1akv5MVAQ/amb7AQ0zW5nzrquTIE2lvrc6q58KZhQA61Vc05ZOg==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.4.tgz", + "integrity": "sha512-EFdy73+lULQgMJ1WolAymrxWWrPv9DWyDuDFKKlUip2PA/EXuHptzfYOKWljccFWDKhhGOu3dqNmoc2f/h/Ecg==", "dev": true, "license": "MIT", "dependencies": { @@ -2356,6 +2300,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/justified-layout": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.4.tgz", @@ -2425,12 +2376,6 @@ "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" }, - "node_modules/@types/pug": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.7.tgz", - "integrity": "sha512-I469DU0UXNC1aHepwirWhu9YKg5fkxohZD95Ey/5A7lovC+Siu+MCLffva87lnfThaOrw9Vb1DUN5t55oULAAw==", - "dev": true - }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -2440,32 +2385,32 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", - "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/type-utils": "7.17.0", - "@typescript-eslint/utils": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2474,27 +2419,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", - "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2503,17 +2448,17 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", - "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2521,27 +2466,24 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", - "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.17.0", - "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.56.0" - }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -2549,13 +2491,13 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", - "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2563,23 +2505,23 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", - "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/visitor-keys": "7.17.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -2617,80 +2559,62 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/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, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", - "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.17.0", - "@typescript-eslint/types": "7.17.0", - "@typescript-eslint/typescript-estree": "7.17.0" + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", - "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz", + "integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -2700,17 +2624,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.3", + "vitest": "2.1.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2718,11 +2649,40 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", + "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -2731,12 +2691,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.3", "pathe": "^1.1.2" }, "funding": { @@ -2744,13 +2705,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -2758,10 +2720,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -2770,13 +2733,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2785,9 +2748,9 @@ } }, "node_modules/@zoom-image/core": { - "version": "0.36.2", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.36.2.tgz", - "integrity": "sha512-NtqIA82xJUtTS8RMey3VUGF/q1tjkFZZUAR6edGdtiy43xIV7a239uuxomuU94WBkBtFztXL/ieyxxL8iPiyFg==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.39.0.tgz", + "integrity": "sha512-JD6UghIOvfdRdI5FCFQRtvaJGht2gIpkzFp+5NrcwKXbHQwSfl00VQ9JQ0TYbaeHa6tc+dxgepYgJukCtrPVgg==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2798,25 +2761,25 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.19.tgz", - "integrity": "sha512-mnQ8eEmUkGi/UolaReQJmEsQu7DmX+8Y+5cdcS6nHmIM/LZImClB53/AySjJym+y5ZbDLUOOc7phgijTkPYz9g==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.3.0.tgz", + "integrity": "sha512-0dfAPgpGRm+j6d3fn044swV7r821l2ZFJZmR0WqUATUUaPZ3GbDkDyrVuZGmP7s4QAk/Nvs1F3+cBhcMWt9Zfw==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.36.2" + "@zoom-image/core": "0.39.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/willnguyen1312" }, "peerDependencies": { - "svelte": "^3.0.0 || ^4.0.0" + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, "node_modules/acorn": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", - "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -2932,21 +2895,12 @@ "node": ">=0.10.0" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -2968,9 +2922,9 @@ "peer": true }, "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "dev": true, "funding": [ { @@ -2986,12 +2940,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -3050,9 +3005,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, "funding": [ { @@ -3068,11 +3023,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -3081,15 +3037,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3130,6 +3077,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3153,9 +3101,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001600", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz", - "integrity": "sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "dev": true, "funding": [ { @@ -3170,13 +3118,15 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -3207,6 +3157,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -3417,14 +3368,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -3434,11 +3377,6 @@ "node": ">= 0.6" } }, - "node_modules/copy-image-clipboard": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/copy-image-clipboard/-/copy-image-clipboard-2.1.2.tgz", - "integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA==" - }, "node_modules/core-js-compat": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", @@ -3484,6 +3422,13 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3588,6 +3533,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3634,15 +3580,6 @@ "node": ">=6" } }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -3653,10 +3590,11 @@ } }, "node_modules/devalue": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", - "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", - "dev": true + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "dev": true, + "license": "MIT" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -3664,37 +3602,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3730,10 +3643,11 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.701", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.701.tgz", - "integrity": "sha512-K3WPQ36bUOtXg/1+69bFlFOvdSm0/0bGqmsfPDLRXLanoKXdA+pIWuf/VbA9b+2CwBFuONgl4NEz4OEm+OJOKA==", - "dev": true + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -3742,35 +3656,16 @@ "dev": true }, "node_modules/engine.io-client": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", - "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", + "integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-parser": { @@ -3829,12 +3724,6 @@ "es6-symbol": "^3.1.1" } }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true - }, "node_modules/es6-symbol": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", @@ -3898,10 +3787,11 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3916,58 +3806,64 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "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" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-compat-utils": { @@ -3986,39 +3882,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-compat-utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-config-prettier": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", @@ -4032,9 +3895,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.0.tgz", + "integrity": "sha512-1A7iEMkzmCZ9/Iz+EAfOGYL8IoIG6zeKEq1SmpxGeM5SXmoQq+ZNnCpXFVJpsxPWYx8jIVGMerQMzX20cqUl0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4042,13 +3905,13 @@ "@jridgewell/sourcemap-codec": "^1.4.15", "eslint-compat-utils": "^0.5.1", "esutils": "^2.0.3", - "known-css-properties": "^0.34.0", + "known-css-properties": "^0.35.0", "postcss": "^8.4.38", "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.41.0" + "svelte-eslint-parser": "^0.43.0" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -4058,7 +3921,7 @@ }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -4066,19 +3929,6 @@ } } }, - "node_modules/eslint-plugin-svelte/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, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -4113,19 +3963,6 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.8.0.tgz", - "integrity": "sha512-VZAJ4cewHTExBWDHR6yptdIBlx9YSSZuwojj9Nt5mBRXQzrKakDsVKQ1J63sklLvzAJm0X5+RpO4i3Y2hcOnFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -4139,19 +3976,6 @@ "node": ">=6" } }, - "node_modules/eslint-plugin-unicorn/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, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -4180,11 +4004,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/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==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4200,6 +4032,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4216,6 +4049,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4227,13 +4061,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eslint/node_modules/escape-string-regexp": { "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" }, @@ -4241,19 +4077,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "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": { - "type-fest": "^0.20.2" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "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", + "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/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": { @@ -4261,6 +4130,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4270,6 +4140,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4373,41 +4244,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -4428,10 +4264,11 @@ } }, "node_modules/factory.ts": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/factory.ts/-/factory.ts-1.4.1.tgz", - "integrity": "sha512-x5hrzGOZvQnw82ZK+fUo/p1nlbJGCi564FBx3jQWQix6xyEK8xvdCwjdgdmbaUiqfURWWfjgTJyBU5OSfs52tw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/factory.ts/-/factory.ts-1.4.2.tgz", + "integrity": "sha512-8x2hqK1+EGkja4Ah8H3nkP7rDUJsBK1N3iFDqzqsaOV114o2IphSdVkFIw9nDHHr37gFFy2NXeN6n10ieqHzZg==", "dev": true, + "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", "source-map-support": "^0.5.21" @@ -4447,10 +4284,11 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -4501,15 +4339,16 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -4541,24 +4380,25 @@ } }, "node_modules/flat-cache": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", - "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { - "flatted": "^3.2.7", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" }, "node_modules/foreground-child": { "version": "3.2.1", @@ -4631,17 +4471,6 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -4656,15 +4485,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4689,26 +4509,6 @@ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": 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/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4746,14 +4546,16 @@ } }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", "dev": true, - "optional": true, - "peer": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalyzer": { @@ -4761,44 +4563,24 @@ "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC", + "peer": true + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4896,15 +4678,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5043,14 +4816,15 @@ } }, "node_modules/intl-messageformat": { - "version": "10.5.14", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.5.14.tgz", - "integrity": "sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.3.tgz", + "integrity": "sha512-AAo/3oyh7ROfPhDuh7DxTTydh97OC+lv7h1Eq5LuHWuLsUMKOhtzTYuyXlUReuwZ9vANDHo4CS1bGRrn7TZRtg==", + "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.0.0", - "@formatjs/fast-memoize": "2.2.0", - "@formatjs/icu-messageformat-parser": "2.7.8", - "tslib": "^2.4.0" + "@formatjs/ecma402-abstract": "2.2.1", + "@formatjs/fast-memoize": "2.2.2", + "@formatjs/icu-messageformat-parser": "2.9.1", + "tslib": "2" } }, "node_modules/is-arrayish": { @@ -5160,15 +4934,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -5201,18 +4966,6 @@ "@types/estree": "*" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -5393,25 +5146,12 @@ } } }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -5436,20 +5176,6 @@ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/just-compare": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/just-compare/-/just-compare-2.3.0.tgz", @@ -5471,10 +5197,11 @@ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" }, "node_modules/keyv": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", - "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -5497,9 +5224,9 @@ } }, "node_modules/known-css-properties": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz", - "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", "dev": true, "license": "MIT" }, @@ -5569,24 +5296,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "yallist": "^3.0.2" - } + "license": "MIT" }, "node_modules/lru-queue": { "version": "0.1.0", @@ -5597,9 +5311,10 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", "engines": { "node": ">=12" } @@ -5614,12 +5329,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { @@ -5648,16 +5363,76 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/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, - "bin": { - "semver": "bin/semver.js" + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" }, "engines": { - "node": ">=10" + "node": ">=6.4.0" + } + }, + "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "peer": true, + "dependencies": { + "kdbush": "^3.0.0" } }, "node_modules/maplibre-gl": { @@ -5723,12 +5498,6 @@ "node": ">=0.12" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5776,18 +5545,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "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", @@ -5826,18 +5583,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -5911,10 +5656,11 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" }, "node_modules/normalize-package-data": { "version": "2.5.0", @@ -5955,33 +5701,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", @@ -6017,21 +5736,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -6210,27 +5914,19 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } @@ -6258,9 +5954,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -6313,9 +6009,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -6334,8 +6030,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6519,21 +6215,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -6552,9 +6244,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.6.tgz", - "integrity": "sha512-Y1XWLw7vXUQQZmgv1JAEiLcErqUniAF2wO7QJsw8BVMvpLET2dI5WpEIEJx1r11iHVdSMzQxivyfrH9On9t2IQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.7.tgz", + "integrity": "sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6873,26 +6565,12 @@ "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==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.1.tgz", + "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.5" }, @@ -6904,19 +6582,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.21.1", + "@rollup/rollup-android-arm64": "4.21.1", + "@rollup/rollup-darwin-arm64": "4.21.1", + "@rollup/rollup-darwin-x64": "4.21.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.1", + "@rollup/rollup-linux-arm-musleabihf": "4.21.1", + "@rollup/rollup-linux-arm64-gnu": "4.21.1", + "@rollup/rollup-linux-arm64-musl": "4.21.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1", + "@rollup/rollup-linux-riscv64-gnu": "4.21.1", + "@rollup/rollup-linux-s390x-gnu": "4.21.1", + "@rollup/rollup-linux-x64-gnu": "4.21.1", + "@rollup/rollup-linux-x64-musl": "4.21.1", + "@rollup/rollup-win32-arm64-msvc": "4.21.1", + "@rollup/rollup-win32-ia32-msvc": "4.21.1", + "@rollup/rollup-win32-x64-msvc": "4.21.1", "fsevents": "~2.3.2" } }, @@ -7010,30 +6691,6 @@ "optional": true, "peer": true }, - "node_modules/sander": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", - "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", - "dev": true, - "dependencies": { - "es6-promise": "^3.1.2", - "graceful-fs": "^4.1.3", - "mkdirp": "^0.5.1", - "rimraf": "^2.5.2" - } - }, - "node_modules/sander/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7049,14 +6706,16 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "optional": true, - "peer": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/set-cookie-parser": { @@ -7131,39 +6790,6 @@ "@img/sharp-win32-x64": "0.33.3" } }, - "node_modules/sharp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7219,37 +6845,29 @@ "dev": true }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", "dev": true, + "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=18" } }, "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": { @@ -7268,21 +6886,6 @@ "node": ">=10.0.0" } }, - "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", - "minimist": "^1.2.0", - "sander": "^0.5.0" - }, - "bin": { - "sorcery": "bin/sorcery" - } - }, "node_modules/sort-asc": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", @@ -7324,9 +6927,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -7473,18 +7077,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -7584,9 +7176,9 @@ } }, "node_modules/svelte": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", - "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -7609,30 +7201,93 @@ } }, "node_modules/svelte-check": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz", - "integrity": "sha512-61aHMkdinWyH8BkkTX9jPLYxYzaAAz/FK/VQqdr2FiCQQ/q04WCwDlpGbHff1GdrMYTmW8chlTFvRWL9k0A8vg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.5.tgz", + "integrity": "sha512-icBTBZ3ibBaywbXUat3cK6hB5Du+Kq9Z8CRuyLmm64XIe2/r+lQcbuBx/IQgsbrC+kT2jQ0weVpZSSRIPwB6jQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "chokidar": "^3.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", "picocolors": "^1.0.0", - "sade": "^1.7.4", - "svelte-preprocess": "^5.1.3", - "typescript": "^5.0.3" + "sade": "^1.7.4" }, "bin": { "svelte-check": "bin/svelte-check" }, + "engines": { + "node": ">= 18.0.0" + }, "peerDependencies": { - "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/svelte-check/node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/svelte-check/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", "dev": true, "license": "MIT", "dependencies": { @@ -7649,7 +7304,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -7657,6 +7312,12 @@ } } }, + "node_modules/svelte-gestures": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.0.6.tgz", + "integrity": "sha512-kElJnoZrQtlkXE0O/RcKioz9NP0Sxx05j31ohyosNkydo6NOEsZB85mhoaCxOQNjxN+QPumYWfmIUsznYFjihA==", + "license": "MIT" + }, "node_modules/svelte-hmr": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", @@ -7670,9 +7331,10 @@ } }, "node_modules/svelte-i18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.0.tgz", - "integrity": "sha512-4vivjKZADUMRIhTs38JuBNy3unbnh9AFRxWFLxq62P4NHic+/BaIZZlAsvqsCdnp7IdJf5EoSiH6TNdItcjA6g==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.1.tgz", + "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", + "license": "MIT", "dependencies": { "cli-color": "^2.0.3", "deepmerge": "^4.2.2", @@ -7689,7 +7351,7 @@ "node": ">= 16" }, "peerDependencies": { - "svelte": "^3 || ^4" + "svelte": "^3 || ^4 || ^5" } }, "node_modules/svelte-i18n/node_modules/@esbuild/aix-ppc64": { @@ -7699,6 +7361,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "aix" @@ -7714,6 +7377,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -7729,6 +7393,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -7744,6 +7409,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -7759,6 +7425,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7774,6 +7441,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7789,6 +7457,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -7804,6 +7473,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -7819,6 +7489,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7834,6 +7505,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7849,6 +7521,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7864,6 +7537,7 @@ "cpu": [ "loong64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7879,6 +7553,7 @@ "cpu": [ "mips64el" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7894,6 +7569,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7909,6 +7585,7 @@ "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7924,6 +7601,7 @@ "cpu": [ "s390x" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7939,6 +7617,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -7954,6 +7633,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -7969,6 +7649,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -7984,6 +7665,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "sunos" @@ -7999,6 +7681,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -8014,6 +7697,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -8029,6 +7713,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -8042,6 +7727,7 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -8077,7 +7763,8 @@ "node_modules/svelte-i18n/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/svelte-local-storage-store": { "version": "0.6.4", @@ -8091,12 +7778,13 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.9.tgz", - "integrity": "sha512-y0NbKGquYCtQQi3vF1M09++Gg8TR5u/4zie1Rb2FIQI8XpvlBJJbBOsY8rkAGjRkH8t2BBtGstCRuoVHzkq3lA==", + "version": "0.9.14", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.14.tgz", + "integrity": "sha512-5HBvibzU/Uf3g8eEz4Hty5XAwoBhW9Tp7NQEvb80U/glR/M1IHyzUKss6XMq8Zbci2wtsASeoPc6dA5R4+0e0w==", "license": "MIT", "dependencies": { "d3-geo": "^3.1.0", + "dequal": "^2.0.3", "just-compare": "^2.3.0", "just-flush": "^2.3.0", "maplibre-gl": "^4.0.0", @@ -8121,78 +7809,16 @@ } }, "node_modules/svelte-parse-markup": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.2.tgz", - "integrity": "sha512-DycY7DJr7VqofiJ63ut1/NEG92HrWWL56VWITn/cJCu+LlZhMoBkBXT4opUitPEEwbq1nMQbv4vTKUfbOqIW1g==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz", + "integrity": "sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://bjornlu.com/sponsor" }, "peerDependencies": { - "svelte": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/svelte-preprocess": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", - "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/pug": "^2.0.6", - "detect-indent": "^6.1.0", - "magic-string": "^0.30.5", - "sorcery": "^0.11.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">= 16.0.0", - "pnpm": "^8.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": "^0.55.0", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", - "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, "node_modules/symbol-tree": { @@ -8204,9 +7830,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", - "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", "dev": true, "license": "MIT", "dependencies": { @@ -8291,9 +7917,9 @@ } }, "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", "dev": true, "license": "ISC", "bin": { @@ -8389,9 +8015,9 @@ } }, "node_modules/three": { - "version": "0.166.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz", - "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==", + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", "license": "MIT" }, "node_modules/thumbhash": { @@ -8418,10 +8044,18 @@ } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -8447,10 +8081,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -8535,9 +8170,9 @@ "dev": true }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "license": "0BSD" }, "node_modules/type": { @@ -8557,22 +8192,10 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8634,9 +8257,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "funding": [ { @@ -8652,9 +8275,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -8701,15 +8325,15 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -8728,6 +8352,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -8745,6 +8370,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -8770,15 +8398,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -8806,29 +8434,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8843,8 +8472,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", "happy-dom": "*", "jsdom": "*" }, @@ -9088,12 +8717,10 @@ "dev": true }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "dev": true, - "optional": true, - "peer": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -9130,9 +8757,9 @@ "peer": 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.1", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", + "integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", "engines": { "node": ">=0.4.0" } @@ -9146,14 +8773,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/web/package.json b/web/package.json index 8849d14704..103f4535f9 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.111.0", + "version": "1.119.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -23,12 +23,14 @@ "prepare": "svelte-kit sync" }, "devDependencies": { - "@faker-js/faker": "^8.4.1", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.8.0", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", - "@sveltejs/kit": "^2.5.2", - "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.2", "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.5.2", @@ -36,27 +38,28 @@ "@types/justified-layout": "^4.1.4", "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", - "@typescript-eslint/eslint-plugin": "^7.1.0", - "@typescript-eslint/parser": "^7.1.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", "autoprefixer": "^10.4.17", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.35.1", + "eslint-plugin-svelte": "^2.43.0", "eslint-plugin-unicorn": "^55.0.0", "factory.ts": "^1.4.1", + "globals": "^15.9.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-sort-json": "^4.0.0", - "prettier-plugin-svelte": "^3.2.1", + "prettier-plugin-svelte": "^3.2.6", "rollup-plugin-visualizer": "^5.12.0", - "svelte": "^4.2.12", - "svelte-check": "^3.6.5", + "svelte": "^4.2.19", + "svelte-check": "^4.0.0", "tailwindcss": "^3.4.1", "tslib": "^2.6.2", - "typescript": "^5.3.3", + "typescript": "^5.5.0", "vite": "^5.1.4", "vitest": "^2.0.5" }, @@ -64,12 +67,12 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", "@photo-sphere-viewer/video-plugin": "^5.7.2", - "@zoom-image/svelte": "^0.2.6", - "copy-image-clipboard": "^2.1.2", + "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", "handlebars": "^4.7.8", "intl-messageformat": "^10.5.14", @@ -77,12 +80,13 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "socket.io-client": "^4.7.4", + "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "volta": { - "node": "20.16.0" + "node": "22.11.0" } } diff --git a/web/src/app.css b/web/src/app.css index 28ab712684..d1af865bca 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -29,7 +29,8 @@ src: url('$lib/assets/fonts/overpass/Overpass.ttf') format('truetype-variations'); font-weight: 1 999; font-style: normal; - ascent-override: 100%; + ascent-override: 106.25%; + size-adjust: 106.25%; } @font-face { @@ -37,7 +38,8 @@ src: url('$lib/assets/fonts/overpass/OverpassMono.ttf') format('truetype-variations'); font-weight: 1 999; font-style: monospace; - ascent-override: 100%; + ascent-override: 106.25%; + size-adjust: 106.25%; } :root { @@ -57,7 +59,6 @@ html { height: 100%; width: 100%; - font-size: 17px; } html::-webkit-scrollbar { @@ -142,46 +143,4 @@ input:focus-visible { .scrollbar-stable { scrollbar-gutter: stable both-edges; } - - /* Supporter Effect */ - .supporter-effect { - position: relative; - border: 0px solid transparent; - background-clip: padding-box; - animation: gradient 10s ease infinite; - z-index: 1; - } - - .supporter-effect:hover:after { - position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - background: linear-gradient( - to right, - rgba(16, 132, 254, 0.25), - rgba(229, 125, 175, 0.25), - rgba(254, 36, 29, 0.25), - rgba(255, 183, 0, 0.25), - rgba(22, 193, 68, 0.25) - ); - content: ''; - border-radius: 8px; - animation: gradient 10s ease infinite; - background-size: 400% 400%; - z-index: -1; - } - - @keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } - } } diff --git a/web/src/app.d.ts b/web/src/app.d.ts index ae6c5b559b..ccec3f33d6 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -18,17 +18,40 @@ declare namespace App { } } -// Source: https://stackoverflow.com/questions/63814432/typescript-typing-of-non-standard-window-event-in-svelte -// To fix the { - 'on:copyImage'?: () => void; - 'on:zoomImage'?: () => void; - } -} - declare module '$env/static/public' { export const PUBLIC_IMMICH_PAY_HOST: string; export const PUBLIC_IMMICH_BUY_HOST: string; } + +interface Element { + // Make optional, because it's unavailable on iPhones. + requestFullscreen?(options?: FullscreenOptions): Promise; +} + +import type en from '$lib/en.json'; +import 'svelte-i18n'; + +type NestedKeys = K extends keyof T & string + ? `${K}` | (T[K] extends object ? `${K}.${NestedKeys}` : never) + : never; + +declare module 'svelte-i18n' { + import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte'; + import type { Readable } from 'svelte/store'; + + type Translations = NestedKeys; + + interface MessageObject { + id: Translations; + locale?: string; + format?: string; + default?: string; + values?: InterpolationValues; + } + + type MessageFormatter = (id: Translations | MessageObject, options?: Omit) => string; + + const format: Readable; + const t: Readable; + const _: Readable; +} diff --git a/web/src/app.html b/web/src/app.html index d1db02f493..6fd02dc9f8 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,5 +1,5 @@ - + @@ -13,36 +13,85 @@ + + %sveltekit.head% +
dispatch('command', { command: JobCommand.ClearFailed, force: false })} + on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })} />
@@ -117,54 +118,56 @@ dispatch('command', { command: JobCommand.Start, force: false })} + on:click={() => onCommand({ command: JobCommand.Start, force: false })} > {$t('disabled').toUpperCase()} - {:else if !isIdle} + {/if} + + {#if !disabled && !isIdle} {#if waitingCount > 0} - dispatch('command', { command: JobCommand.Empty, force: false })}> + onCommand({ command: JobCommand.Empty, force: false })}> {$t('clear').toUpperCase()} {/if} {#if queueStatus.isPaused} {@const size = waitingCount > 0 ? '24' : '48'} - dispatch('command', { command: JobCommand.Resume, force: false })} - > + onCommand({ command: JobCommand.Resume, force: false })}> {$t('resume').toUpperCase()} {:else} - dispatch('command', { command: JobCommand.Pause, force: false })} - > + onCommand({ command: JobCommand.Pause, force: false })}> {$t('pause').toUpperCase()} {/if} - {:else if allowForceCommand} - dispatch('command', { command: JobCommand.Start, force: true })}> - - {allText} - - dispatch('command', { command: JobCommand.Start, force: false })} - > + {/if} + + {#if !disabled && multipleButtons && isIdle} + {#if allText} + onCommand({ command: JobCommand.Start, force: true })}> + + {allText} + + {/if} + {#if refreshText} + onCommand({ command: JobCommand.Start, force: undefined })}> + + {refreshText} + + {/if} + onCommand({ command: JobCommand.Start, force: false })}> {missingText} - {:else} - dispatch('command', { command: JobCommand.Start, force: false })} - > + {/if} + + {#if !disabled && !multipleButtons && isIdle} + onCommand({ command: JobCommand.Start, force: false })}> {$t('start').toUpperCase()} 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 cd9855eea2..8702a1e933 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -32,10 +32,10 @@ subtitle?: string; description?: ComponentType; allText?: string; - missingText?: string; + refreshText?: string; + missingText: string; disabled?: boolean; icon: string; - allowForceCommand?: boolean; handleCommand?: (jobId: JobName, jobCommand: JobCommandDto) => Promise; } @@ -61,43 +61,54 @@ icon: mdiFileJpgBox, title: $getJobName(JobName.ThumbnailGeneration), subtitle: $t('admin.thumbnail_generation_job_description'), + allText: $t('all'), + missingText: $t('missing'), }, [JobName.MetadataExtraction]: { icon: mdiTable, title: $getJobName(JobName.MetadataExtraction), subtitle: $t('admin.metadata_extraction_job_description'), + allText: $t('all'), + missingText: $t('missing'), }, [JobName.Library]: { icon: mdiLibraryShelves, title: $getJobName(JobName.Library), subtitle: $t('admin.library_tasks_description'), - allText: $t('all').toUpperCase(), - missingText: $t('refresh').toUpperCase(), + allText: $t('all'), + missingText: $t('refresh'), }, [JobName.Sidecar]: { title: $getJobName(JobName.Sidecar), icon: mdiFileXmlBox, subtitle: $t('admin.sidecar_job_description'), - allText: $t('sync').toUpperCase(), - missingText: $t('discover').toUpperCase(), + allText: $t('sync'), + missingText: $t('discover'), disabled: !$featureFlags.sidecar, }, [JobName.SmartSearch]: { icon: mdiImageSearch, title: $getJobName(JobName.SmartSearch), subtitle: $t('admin.smart_search_job_description'), + allText: $t('all'), + missingText: $t('missing'), disabled: !$featureFlags.smartSearch, }, [JobName.DuplicateDetection]: { icon: mdiContentDuplicate, title: $getJobName(JobName.DuplicateDetection), subtitle: $t('admin.duplicate_detection_job_description'), + allText: $t('all'), + missingText: $t('missing'), disabled: !$featureFlags.duplicateDetection, }, [JobName.FaceDetection]: { icon: mdiFaceRecognition, title: $getJobName(JobName.FaceDetection), subtitle: $t('admin.face_detection_description'), + allText: $t('reset'), + refreshText: $t('refresh'), + missingText: $t('missing'), handleCommand: handleConfirmCommand, disabled: !$featureFlags.facialRecognition, }, @@ -105,6 +116,8 @@ icon: mdiTagFaces, title: $getJobName(JobName.FacialRecognition), subtitle: $t('admin.facial_recognition_job_description'), + allText: $t('reset'), + missingText: $t('missing'), handleCommand: handleConfirmCommand, disabled: !$featureFlags.facialRecognition, }, @@ -112,18 +125,20 @@ icon: mdiVideo, title: $getJobName(JobName.VideoConversion), subtitle: $t('admin.video_conversion_job_description'), + allText: $t('all'), + missingText: $t('missing'), }, [JobName.StorageTemplateMigration]: { icon: mdiFolderMove, title: $getJobName(JobName.StorageTemplateMigration), - allowForceCommand: false, + missingText: $t('missing'), description: StorageMigrationDescription, }, [JobName.Migration]: { icon: mdiFolderMove, title: $getJobName(JobName.Migration), subtitle: $t('admin.migration_job_description'), - allowForceCommand: false, + missingText: $t('missing'), }, }; $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][]; @@ -150,7 +165,7 @@
- {#each jobList as [jobName, { title, subtitle, description, disabled, allText, missingText, allowForceCommand, icon, handleCommand: handleCommandOverride }]} + {#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }]} {@const { jobCounts, queueStatus } = jobs[jobName]} (handleCommandOverride || handleCommand)(jobName, detail)} + onCommand={(command) => (handleCommandOverride || handleCommand)(jobName, command)} /> {/each}
diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte index 9b274d2c2f..25afbc6d4b 100644 --- a/web/src/lib/components/admin-page/restore-dialogue.svelte +++ b/web/src/lib/components/admin-page/restore-dialogue.svelte @@ -3,28 +3,24 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { handleError } from '$lib/utils/handle-error'; import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; export let user: UserResponseDto; - - const dispatch = createEventDispatcher<{ - success: void; - fail: void; - cancel: void; - }>(); + export let onSuccess: () => void; + export let onFail: () => void; + export let onCancel: () => void; const handleRestoreUser = async () => { try { const { deletedAt } = await restoreUserAdmin({ id: user.id }); if (deletedAt == undefined) { - dispatch('success'); + onSuccess(); } else { - dispatch('fail'); + onFail(); } } catch (error) { handleError(error, $t('errors.unable_to_restore_user')); - dispatch('fail'); + onFail(); } }; @@ -34,7 +30,7 @@ confirmText={$t('continue')} confirmColor="green" onConfirm={handleRestoreUser} - onCancel={() => dispatch('cancel')} + {onCancel} >

diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 55750a9737..19a8580d6b 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -7,8 +7,8 @@ } from '$lib/components/shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk'; - import { loadConfig } from '$lib/stores/server-config.store'; - import { cloneDeep } from 'lodash-es'; + import { retrieveServerConfig } from '$lib/stores/server-config.store'; + import { cloneDeep, isEqual } from 'lodash-es'; import { onMount } from 'svelte'; import type { SettingsResetOptions } from './admin-settings'; import { t } from 'svelte-i18n'; @@ -23,19 +23,23 @@ }; export const handleSave = async (update: Partial) => { + let systemConfigDto = { + ...savedConfig, + ...update, + }; + if (isEqual(systemConfigDto, savedConfig)) { + return; + } try { const newConfig = await updateConfig({ - systemConfigDto: { - ...savedConfig, - ...update, - }, + systemConfigDto, }); config = cloneDeep(newConfig); savedConfig = cloneDeep(newConfig); notificationController.show({ message: $t('settings_saved'), type: NotificationType.Info }); - await loadConfig(); + await retrieveServerConfig(); } catch (error) { handleError(error, $t('errors.unable_to_save_settings')); } diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 34514dc125..9b0e4b3270 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -71,7 +71,7 @@

-
+
handleToggleOverride()} bind:checked={config.oauth.mobileOverrideEnabled} diff --git a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte new file mode 100644 index 0000000000..05543f1124 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte @@ -0,0 +1,91 @@ + + +
+
+ +
+ + + + + + +

+ + + {message} +
+
+
+

+
+
+ + + + onReset({ ...options, configKeys: ['backup'] })} + onSave={() => onSave({ backup: config.backup })} + showResetToDefault={!isEqual(savedConfig.backup, defaultConfig.backup)} + {disabled} + /> +
+ +
+
diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 7ddb71cbde..42cc004c52 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -99,9 +99,10 @@ ]} name="vcodec" isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - on:select={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} /> + + onSelect={() => config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) ? null : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} @@ -145,6 +146,7 @@ { value: AudioCodec.Aac, text: 'AAC' }, { value: AudioCodec.Mp3, text: 'MP3' }, { value: AudioCodec.Libopus, text: 'Opus' }, + { value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' }, ]} isEdited={!isEqual(sortBy(config.ffmpeg.acceptedAudioCodecs), sortBy(savedConfig.ffmpeg.acceptedAudioCodecs))} /> diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index a7b47920fd..50ae494570 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -18,85 +19,109 @@ export let disabled = false; export let onReset: SettingsResetEvent; export let onSave: SettingsSaveEvent; + export let openByDefault = false;
- + + - + - + + - + + - + + + + (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)} + onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> @@ -105,7 +130,7 @@ title={$t('admin.image_prefer_embedded_preview')} subtitle={$t('admin.image_prefer_embedded_preview_setting_description')} checked={config.image.extractEmbedded} - on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} {disabled} /> diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index f6ed132c8c..b494dca53f 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -11,6 +11,7 @@ import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -18,106 +19,89 @@ export let disabled = false; export let onReset: SettingsResetEvent; export let onSave: SettingsSaveEvent; + export let openByDefault = false; $: cronExpressionOptions = [ - { title: $t('interval.night_at_midnight'), expression: '0 0 * * *' }, - { title: $t('interval.night_at_twoam'), expression: '0 2 * * *' }, - { title: $t('interval.day_at_onepm'), expression: '0 13 * * *' }, - { title: $t('interval.hours', { values: { hours: 6 } }), expression: '0 */6 * * *' }, + { text: $t('interval.night_at_midnight'), value: '0 0 * * *' }, + { text: $t('interval.night_at_twoam'), value: '0 2 * * *' }, + { text: $t('interval.day_at_onepm'), value: '0 13 * * *' }, + { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' }, ];
- - -
- -
+ +
+ +
+ +
+
-
- onReset({ ...options, configKeys: ['library'] })} - onSave={() => onSave({ library: config.library })} - showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} - {disabled} - /> -
- - + +
+ - -
-
- - -
- - + +

+ + + {message} + + +

+
+
+ - - -

- - - {message} - - -

-
-
-
- -
- onReset({ ...options, configKeys: ['library'] })} - onSave={() => onSave({ library: config.library })} - showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} - {disabled} - /> -
-
-
+ onReset({ ...options, configKeys: ['library'] })} + onSave={() => onSave({ library: config.library })} + showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} + {disabled} + /> +
+
diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 05a5224bd0..aac8cd5212 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -145,7 +145,7 @@ desc={$t('admin.machine_learning_min_detection_score_description')} bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" - min={0} + min={0.1} max={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.minScore !== @@ -158,7 +158,7 @@ desc={$t('admin.machine_learning_max_recognition_distance_description')} bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" - min={0} + min={0.1} max={2} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.maxDistance !== diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index 74cbe2d9a1..7c2c5c856a 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -26,7 +26,12 @@
- +
diff --git a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte new file mode 100644 index 0000000000..c28050e022 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte @@ -0,0 +1,38 @@ + + +
+
+
+
+ +
+ + onReset({ ...options, configKeys: ['metadata'] })} + onSave={() => onSave({ metadata: config.metadata })} + showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)} + {disabled} + /> + +
+
diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte index 4ef4804c3f..76c238df82 100644 --- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte +++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte @@ -21,6 +21,7 @@
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 1d0cec3296..4ebf4ed118 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 @@ -29,6 +29,7 @@ export let minified = false; export let onReset: SettingsResetEvent; export let onSave: SettingsSaveEvent; + export let duration: number = 500; let templateOptions: SystemConfigTemplateStorageOptionDto; let selectedPreset = ''; @@ -87,7 +88,7 @@
-
+

{#if tag === 'template-link'} diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 6ffa273a4d..8da9fbfd45 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,5 +1,5 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; -import { albumFactory } from '@test-data'; +import { albumFactory } from '@test-data/factories/album-factory'; import '@testing-library/jest-dom'; import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; import { init, register, waitLocale } from 'svelte-i18n'; @@ -12,7 +12,7 @@ describe('AlbumCard component', () => { beforeAll(async () => { await init({ fallbackLocale: 'en-US' }); - register('en-US', () => import('$lib/i18n/en.json')); + register('en-US', () => import('$i18n/en.json')); await waitLocale('en-US'); }); diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 4f5fb7e571..5fa8b96008 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -1,6 +1,6 @@ import AlbumCover from '$lib/components/album-page/album-cover.svelte'; import { getAssetThumbnailUrl } from '$lib/utils'; -import { albumFactory } from '@test-data'; +import { albumFactory } from '@test-data/factories/album-factory'; import { render } from '@testing-library/svelte'; vi.mock('$lib/utils'); @@ -19,7 +19,7 @@ describe('AlbumCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('someName'); expect(img.getAttribute('loading')).toBe('lazy'); - expect(img.className).toBe('z-0 rounded-xl object-cover text'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); }); @@ -36,7 +36,7 @@ describe('AlbumCover component', () => { const img = component.getByTestId('album-image') as HTMLImageElement; expect(img.alt).toBe('unnamed_album'); expect(img.getAttribute('loading')).toBe('eager'); - expect(img.className).toBe('z-0 rounded-xl object-cover asdf'); + expect(img.className).toBe('size-full rounded-xl object-cover aspect-square asdf'); expect(img.getAttribute('src')).toStrictEqual(expect.any(String)); }); }); diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 0e731a683c..f899cebd8c 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -50,7 +50,7 @@

{#if !isCollapsed} -
+
{#each albums as album, index (album.id)} {/if} - +

-

- {#if thumbnailUrl} - - {:else} - - {/if} -
+{#if thumbnailUrl} + +{:else} + +{/if} diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 84a2873788..3ec1842757 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -1,8 +1,15 @@ - dispatch('close')}> -
-
-

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

-
- {#if order} - +
+
+

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

+
+ {#if order} + + {/if} + - {/if} - dispatch('toggleEnableActivity')} - /> -
-
-
-
{$t('people').toUpperCase()}
-
- -
-
- -
-
{user.name}
-
{$t('owner')}
- {#each album.albumUsers as { user } (user.id)} -
+
+
+
{$t('people').toUpperCase()}
+
+ + +
{user.name}
+
{$t('owner')}
- {/each} + + {#each album.albumUsers as { user, role } (user.id)} +
+
+ +
+
{user.name}
+ {#if role === AlbumUserRole.Viewer} + {$t('role_viewer')} + {:else} + {$t('role_editor')} + {/if} + {#if user.id !== album.ownerId} + + {#if role === AlbumUserRole.Viewer} + handleUpdateSharedUserRole(user, AlbumUserRole.Editor)} + text={$t('allow_edits')} + /> + {:else} + handleUpdateSharedUserRole(user, AlbumUserRole.Viewer)} + text={$t('disallow_edits')} + /> + {/if} + + handleMenuRemove(user)} text={$t('remove')} /> + + {/if} +
+ {/each} +
-
- + +{/if} + +{#if selectedRemoveUser} + (selectedRemoveUser = null)} + /> +{/if} diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 22c26aa10c..1e69ecf1a3 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -7,6 +7,7 @@ export let id: string; export let albumName: string; export let isOwned: boolean; + export let onUpdate: (albumName: string) => void; $: newAlbumName = albumName; @@ -16,17 +17,17 @@ } try { - await updateAlbumInfo({ + ({ albumName } = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName, }, - }); + })); + onUpdate(albumName); } catch (error) { handleError(error, $t('errors.unable_to_save_album')); return; } - albumName = newAlbumName; }; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 7a88aa740b..87b3d8e2c5 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -6,7 +6,7 @@ import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { AssetStore } from '$lib/stores/assets.store'; - import { downloadAlbum } from '$lib/utils/asset-utils'; + 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'; import AssetGrid from '../photos-page/asset-grid.svelte'; @@ -19,6 +19,7 @@ import { handlePromiseError } from '$lib/utils'; import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -38,6 +39,9 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); + onDestroy(() => { + assetStore.destroy(); + }); { if (!$showAssetViewer && $isMultiSelectState) { - assetInteractionStore.clearMultiselect(); + cancelMultiselect(assetInteractionStore); } }, }} @@ -94,7 +98,7 @@
- +

{/key} @@ -152,10 +151,8 @@ rounded="full" disabled={Object.keys(selectedUsers).length === 0} on:click={() => - dispatch( - 'select', - Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), - )}>{$t('add')} ({ userId: user.id, ...rest })))} + >{$t('add')}

{/if} @@ -166,7 +163,7 @@ -
+
+ {/each} + +
+ +{/if} + +{#if isOpen} + + handleTag(tagsIds)} onCancel={handleCancel} /> + +{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 6af265e3da..88ea98778f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -1,15 +1,21 @@ + +
+ +
+ + diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte new file mode 100644 index 0000000000..667191274f --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte @@ -0,0 +1,40 @@ + + +
  • + +
  • diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts new file mode 100644 index 0000000000..a0390d2d4d --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts @@ -0,0 +1,159 @@ +import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl } from './crop-store'; +import { checkEdits } from './mouse-handlers'; + +export function recalculateCrop( + crop: CropSettings, + canvas: HTMLElement, + aspectRatio: CropAspectRatio, + returnNewCrop = false, +): CropSettings | null { + const canvasW = canvas.clientWidth; + const canvasH = canvas.clientHeight; + + let newWidth = crop.width; + let newHeight = crop.height; + + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio); + + if (w > canvasW) { + newWidth = canvasW; + newHeight = canvasW / (w / h); + } else if (h > canvasH) { + newHeight = canvasH; + newWidth = canvasH * (w / h); + } else { + newWidth = w; + newHeight = h; + } + + const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth)); + const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight)); + + const newCrop = { + width: newWidth, + height: newHeight, + x: newX, + y: newY, + }; + + if (returnNewCrop) { + setTimeout(() => { + checkEdits(); + }, 1); + return newCrop; + } else { + crop.width = newWidth; + crop.height = newHeight; + crop.x = newX; + crop.y = newY; + return null; + } +} + +export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { + const cropArea = get(cropAreaEl); + if (!cropArea) { + return; + } + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (!cropFrame) { + return; + } + + const startTime = performance.now(); + const initialCrop = { ...crop }; + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress; + crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress; + crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress; + crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress; + + draw(); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); +} + +export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + + if (widthRatio && heightRatio) { + const calculatedWidth = (newHeight * widthRatio) / heightRatio; + return { newWidth: calculatedWidth, newHeight }; + } + + return { newWidth, newHeight }; +} + +export function adjustDimensions( + newWidth: number, + newHeight: number, + aspectRatio: CropAspectRatio, + xLimit: number, + yLimit: number, + minSize: number, +) { + let w = newWidth; + let h = newHeight; + + let aspectMultiplier: number; + + if (aspectRatio === 'free') { + aspectMultiplier = newWidth / newHeight; + } else { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; + } + + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + + if (w > xLimit) { + w = xLimit; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h > yLimit) { + h = yLimit; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (w < minSize) { + w = minSize; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h < minSize) { + h = minSize; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w < minSize) { + h = w / aspectMultiplier; + } + if (h < minSize) { + w = h * aspectMultiplier; + } + } + + return { newWidth: w, newHeight: h }; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts new file mode 100644 index 0000000000..8e27d41f21 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export const darkenLevel = writable(0.65); +export const isResizingOrDragging = writable(false); +export const animationFrame = writable | null>(null); +export const canvasCursor = writable('default'); +export const dragOffset = writable({ x: 0, y: 0 }); +export const resizeSide = writable(''); +export const imgElement = writable(null); +export const cropAreaEl = writable(null); +export const isDragging = writable(false); + +export const overlayEl = writable(null); +export const cropFrame = writable(null); + +export function resetCropStore() { + darkenLevel.set(0.65); + isResizingOrDragging.set(false); + animationFrame.set(null); + canvasCursor.set('default'); + dragOffset.set({ x: 0, y: 0 }); + resizeSide.set(''); + imgElement.set(null); + cropAreaEl.set(null); + isDragging.set(false); + overlayEl.set(null); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte new file mode 100644 index 0000000000..dba3be5d67 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -0,0 +1,151 @@ + + +
    +
    +

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

    +
    + {#each sizesRows as sizesRow} +
      + {#each sizesRow as size (size.name)} + + {/each} +
    + {/each} +
    +

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

    +
    +
      +
    • rotate(false)} icon={mdiRotateLeft} />
    • +
    • rotate(true)} icon={mdiRotateRight} />
    • +
    +
    diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts new file mode 100644 index 0000000000..85e7f4b1c4 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts @@ -0,0 +1,40 @@ +import type { CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropFrame, overlayEl } from './crop-store'; + +export function draw(crop: CropSettings) { + const mCropFrame = get(cropFrame); + + if (!mCropFrame) { + return; + } + + mCropFrame.style.left = `${crop.x}px`; + mCropFrame.style.top = `${crop.y}px`; + mCropFrame.style.width = `${crop.width}px`; + mCropFrame.style.height = `${crop.height}px`; + + drawOverlay(crop); +} + +export function drawOverlay(crop: CropSettings) { + const overlay = get(overlayEl); + if (!overlay) { + return; + } + + overlay.style.clipPath = ` + polygon( + 0% 0%, + 0% 100%, + 100% 100%, + 100% 0%, + 0% 0%, + ${crop.x}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y}px + ) + `; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts new file mode 100644 index 0000000000..bce90efd9e --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -0,0 +1,117 @@ +import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl, cropFrame, imgElement } from './crop-store'; +import { draw } from './drawing'; + +export function onImageLoad(resetSize: boolean = false) { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea.clientWidth ?? 0; + const containerHeight = cropArea.clientHeight ?? 0; + + const scale = calculateScale(img, containerWidth, containerHeight); + + cropImageSize.set([img.width, img.height]); + + if (resetSize) { + cropSettings.update((crop) => { + crop.x = 0; + crop.y = 0; + crop.width = img.width * scale; + crop.height = img.height * scale; + return crop; + }); + } else { + const cropFrameEl = get(cropFrame); + cropFrameEl?.classList.add('transition'); + cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); + cropFrameEl?.classList.add('transition'); + cropFrameEl?.addEventListener('transitionend', () => { + cropFrameEl?.classList.remove('transition'); + }); + } + cropImageScale.set(scale); + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + draw(get(cropSettings)); +} + +export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number { + const imageAspectRatio = img.width / img.height; + let scale: number; + + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + return scale; +} + +export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { + const prevScale = get(cropImageScale); + const scaleRatio = scale / prevScale; + + crop.x *= scaleRatio; + crop.y *= scaleRatio; + crop.width *= scaleRatio; + crop.height *= scaleRatio; + + crop.width = Math.min(crop.width, img.width * scale); + crop.height = Math.min(crop.height, img.height * scale); + crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width)); + crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height)); + + return crop; +} + +export function resizeCanvas() { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea?.clientWidth ?? 0; + const containerHeight = cropArea?.clientHeight ?? 0; + const imageAspectRatio = img.width / img.height; + + let scale; + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (cropFrame) { + cropFrame.style.width = `${img.width * scale}px`; + cropFrame.style.height = `${img.height * scale}px`; + } + + draw(get(cropSettings)); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts new file mode 100644 index 0000000000..656fd09294 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -0,0 +1,536 @@ +import { + cropAspectRatio, + cropImageScale, + cropImageSize, + cropSettings, + cropSettingsChanged, + normaizedRorateDegrees, + rotateDegrees, + showCancelConfirmDialog, + type CropSettings, +} from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { adjustDimensions, keepAspectRatio } from './crop-settings'; +import { + canvasCursor, + cropAreaEl, + dragOffset, + isDragging, + isResizingOrDragging, + overlayEl, + resizeSide, +} from './crop-store'; +import { draw } from './drawing'; + +export function handleMouseDown(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const crop = get(cropSettings); + const { mouseX, mouseY } = getMousePosition(e); + + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if ( + onTopLeftCorner || + onTopRightCorner || + onBottomLeftCorner || + onBottomRightCorner || + onLeftBoundary || + onRightBoundary || + onTopBoundary || + onBottomBoundary + ) { + setResizeSide(mouseX, mouseY); + } else if (isInCropArea(mouseX, mouseY, crop)) { + startDragging(mouseX, mouseY); + } + + document.body.style.userSelect = 'none'; + window.addEventListener('mouseup', handleMouseUp); +} + +export function handleMouseMove(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const resizeSideValue = get(resizeSide); + const { mouseX, mouseY } = getMousePosition(e); + + if (get(isDragging)) { + moveCrop(mouseX, mouseY); + } else if (resizeSideValue) { + resizeCrop(mouseX, mouseY); + } else { + updateCursor(mouseX, mouseY); + } +} + +export function handleMouseUp() { + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.userSelect = ''; + stopInteraction(); +} + +function getMousePosition(e: MouseEvent) { + let offsetX = e.clientX; + let offsetY = e.clientY; + const clienRect = getBoundingClientRectCached(get(cropAreaEl)); + const rotateDeg = get(normaizedRorateDegrees); + + if (rotateDeg == 90) { + offsetX = e.clientY - (clienRect?.top ?? 0); + offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + } else if (rotateDeg == 180) { + offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + } else if (rotateDeg == 270) { + offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + offsetY = e.clientX - (clienRect?.left ?? 0); + } else if (rotateDeg == 0) { + offsetX -= clienRect?.left ?? 0; + offsetY -= clienRect?.top ?? 0; + } + return { mouseX: offsetX, mouseY: offsetY }; +} + +type BoundingClientRect = ReturnType; +let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = { + data: null, + time: 0, +}; +rotateDegrees.subscribe(() => { + getBoundingClientRectCache.time = 0; +}); +function getBoundingClientRectCached(el: HTMLElement | null) { + if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) { + getBoundingClientRectCache = { + time: Date.now(), + data: el?.getBoundingClientRect() ?? null, + }; + } + return getBoundingClientRectCache.data; +} + +function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { + const { x, y, width, height } = crop; + const sensitivity = 10; + const cornerSensitivity = 15; + + const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0; + if (outOfBound) { + return { + onLeftBoundary: false, + onRightBoundary: false, + onTopBoundary: false, + onBottomBoundary: false, + onTopLeftCorner: false, + onTopRightCorner: false, + onBottomLeftCorner: false, + onBottomRightCorner: false, + }; + } + + const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height; + const onRightBoundary = + mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height; + const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width; + const onBottomBoundary = + mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width; + + const onTopLeftCorner = + mouseX >= x - cornerSensitivity && + mouseX <= x + cornerSensitivity && + mouseY >= y - cornerSensitivity && + mouseY <= y + cornerSensitivity; + const onTopRightCorner = + mouseX >= x + width - cornerSensitivity && + mouseX <= x + width + cornerSensitivity && + mouseY >= y - cornerSensitivity && + mouseY <= y + cornerSensitivity; + const onBottomLeftCorner = + mouseX >= x - cornerSensitivity && + mouseX <= x + cornerSensitivity && + mouseY >= y + height - cornerSensitivity && + mouseY <= y + height + cornerSensitivity; + const onBottomRightCorner = + mouseX >= x + width - cornerSensitivity && + mouseX <= x + width + cornerSensitivity && + mouseY >= y + height - cornerSensitivity && + mouseY <= y + height + cornerSensitivity; + + return { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + }; +} + +function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) { + const { x, y, width, height } = crop; + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; +} + +function setResizeSide(mouseX: number, mouseY: number) { + const crop = get(cropSettings); + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if (onTopLeftCorner) { + resizeSide.set('top-left'); + } else if (onTopRightCorner) { + resizeSide.set('top-right'); + } else if (onBottomLeftCorner) { + resizeSide.set('bottom-left'); + } else if (onBottomRightCorner) { + resizeSide.set('bottom-right'); + } else if (onLeftBoundary) { + resizeSide.set('left'); + } else if (onRightBoundary) { + resizeSide.set('right'); + } else if (onTopBoundary) { + resizeSide.set('top'); + } else if (onBottomBoundary) { + resizeSide.set('bottom'); + } +} + +function startDragging(mouseX: number, mouseY: number) { + isDragging.set(true); + const crop = get(cropSettings); + isResizingOrDragging.set(true); + dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y }); + fadeOverlay(false); +} + +function moveCrop(mouseX: number, mouseY: number) { + const cropArea = get(cropAreaEl); + if (!cropArea) { + return; + } + + const crop = get(cropSettings); + const { x, y } = get(dragOffset); + + let newX = mouseX - x; + let newY = mouseY - y; + + newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX)); + newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY)); + + cropSettings.update((crop) => { + crop.x = newX; + crop.y = newY; + return crop; + }); + + draw(crop); +} + +function resizeCrop(mouseX: number, mouseY: number) { + const canvas = get(cropAreaEl); + const crop = get(cropSettings); + const resizeSideValue = get(resizeSide); + if (!canvas || !resizeSideValue) { + return; + } + fadeOverlay(false); + + const { x, y, width, height } = crop; + const minSize = 50; + let newWidth = width; + let newHeight = height; + switch (resizeSideValue) { + case 'left': { + newWidth = width + x - mouseX; + newHeight = height; + if (newWidth >= minSize && mouseX >= 0) { + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); + cropSettings.update((crop) => { + crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth)); + crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); + crop.x = Math.max(0, x + width - crop.width); + return crop; + }); + } + break; + } + case 'right': { + newWidth = mouseX - x; + newHeight = height; + if (newWidth >= minSize && mouseX <= canvas.clientWidth) { + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); + cropSettings.update((crop) => { + crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x)); + crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); + return crop; + }); + } + break; + } + case 'top': { + newHeight = height + y - mouseY; + newWidth = width; + if (newHeight >= minSize && mouseY >= 0) { + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + cropSettings.update((crop) => { + crop.y = Math.max(0, y + height - h); + crop.width = w; + crop.height = h; + return crop; + }); + } + break; + } + case 'bottom': { + newHeight = mouseY - y; + newWidth = width; + if (newHeight >= minSize && mouseY <= canvas.clientHeight) { + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + return crop; + }); + } + break; + } + case 'top-left': { + newWidth = width + x - Math.max(mouseX, 0); + newHeight = height + y - Math.max(mouseY, 0); + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.x = Math.max(0, x + width - crop.width); + crop.y = Math.max(0, y + height - crop.height); + return crop; + }); + break; + } + case 'top-right': { + newWidth = Math.max(mouseX, 0) - x; + newHeight = height + y - Math.max(mouseY, 0); + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth - x, + y + height, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.y = Math.max(0, y + height - crop.height); + return crop; + }); + break; + } + case 'bottom-left': { + newWidth = width + x - Math.max(mouseX, 0); + newHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + crop.x = Math.max(0, x + width - crop.width); + return crop; + }); + break; + } + case 'bottom-right': { + newWidth = Math.max(mouseX, 0) - x; + newHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = adjustDimensions( + newWidth, + newHeight, + get(cropAspectRatio), + canvas.clientWidth - x, + canvas.clientHeight - y, + minSize, + ); + cropSettings.update((crop) => { + crop.width = w; + crop.height = h; + return crop; + }); + break; + } + } + + cropSettings.update((crop) => { + crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width)); + crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height)); + return crop; + }); + + draw(crop); +} + +function updateCursor(mouseX: number, mouseY: number) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const crop = get(cropSettings); + const rotateDeg = get(normaizedRorateDegrees); + + let { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if (rotateDeg == 90) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onLeftBoundary, + onTopBoundary, + onRightBoundary, + onBottomBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onBottomLeftCorner, + onTopLeftCorner, + onTopRightCorner, + onBottomRightCorner, + ]; + } else if (rotateDeg == 180) { + [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; + [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; + + [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; + [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; + } else if (rotateDeg == 270) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onRightBoundary, + onBottomBoundary, + onLeftBoundary, + onTopBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onTopRightCorner, + onBottomRightCorner, + onBottomLeftCorner, + onTopLeftCorner, + ]; + } + if (onTopLeftCorner || onBottomRightCorner) { + setCursor('nwse-resize'); + } else if (onTopRightCorner || onBottomLeftCorner) { + setCursor('nesw-resize'); + } else if (onLeftBoundary || onRightBoundary) { + setCursor('ew-resize'); + } else if (onTopBoundary || onBottomBoundary) { + setCursor('ns-resize'); + } else if (isInCropArea(mouseX, mouseY, crop)) { + setCursor('move'); + } else { + setCursor('default'); + } + + function setCursor(cursorName: string) { + if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) { + canvasCursor.set(cursorName); + document.body.style.cursor = cursorName; + canvas.style.cursor = cursorName; + } + } +} + +function stopInteraction() { + isResizingOrDragging.set(false); + isDragging.set(false); + resizeSide.set(''); + fadeOverlay(true); // Darken the background + + setTimeout(() => { + checkEdits(); + }, 1); +} + +export function checkEdits() { + const cropImageSizeParams = get(cropSettings); + const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale)); + const changed = + Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 || + Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2; + cropSettingsChanged.set(changed); +} + +function fadeOverlay(toDark: boolean) { + const overlay = get(overlayEl); + const cropFrame = document.querySelector('.crop-frame'); + + if (toDark) { + overlay?.classList.remove('light'); + cropFrame?.classList.remove('resizing'); + } else { + overlay?.classList.add('light'); + cropFrame?.classList.add('resizing'); + } + + isResizingOrDragging.set(!toDark); +} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte new file mode 100644 index 0000000000..1adef32735 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -0,0 +1,76 @@ + + + + +
    +
    + +

    {$t('editor')}

    +
    +
    +
      + {#each editTypes as etype (etype.name)} +
    • + selectType(etype.name)} + /> +
    • + {/each} +
    +
    +
    + +
    +
    + +{#if $showCancelConfirmDialog} + { + $showCancelConfirmDialog = false; + }} + onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())} + /> +{/if} diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte deleted file mode 100644 index df89a2ed7d..0000000000 --- a/web/src/lib/components/asset-viewer/intersection-observer.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
    - -
    diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 71ed4b8997..396685e351 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,11 +1,14 @@ {#if imageError} -
    {$t('error_loading_image')}
    + {/if} + +
    (imageError = imageLoaded = true)} /> {#if !imageLoaded} -
    +
    {:else if !imageError} -
    +
    {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {/if}
    + + diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 63e501f6dd..1acc06f21b 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -139,5 +139,5 @@ duration={$slideshowDelay} bind:this={progressBar} bind:status={progressBarStatus} - on:done={handleDone} + onDone={handleDone} /> 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 6186358dcb..58012ccfce 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -4,13 +4,19 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { AssetMediaSize } from '@immich/sdk'; - import { createEventDispatcher, tick } from 'svelte'; + import { tick } from 'svelte'; + import { swipe } from 'svelte-gestures'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; export let assetId: string; export let loopVideo: boolean; export let checksum: string; + export let onPreviousAsset: () => void = () => {}; + export let onNextAsset: () => void = () => {}; + export let onVideoEnded: () => void = () => {}; + export let onVideoStarted: () => void = () => {}; let element: HTMLVideoElement | undefined = undefined; let isVideoLoading = true; @@ -23,12 +29,10 @@ element.load(); } - const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>(); - const handleCanPlay = async (video: HTMLVideoElement) => { try { await video.play(); - dispatch('onVideoStarted'); + onVideoStarted(); } catch (error) { if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) { await tryForceMutedPlay(video); @@ -49,6 +53,15 @@ handleError(error, $t('errors.unable_to_play_video')); } }; + + const onSwipe = (event: SwipeCustomEvent) => { + if (event.detail.direction === 'left') { + onNextAsset(); + } + if (event.detail.direction === 'right') { + onPreviousAsset(); + } + };
    @@ -59,8 +72,10 @@ playsinline controls class="h-full object-contain" + use:swipe + on:swipe={onSwipe} on:canplay={(e) => handleCanPlay(e.currentTarget)} - on:ended={() => dispatch('onVideoEnded')} + on:ended={onVideoEnded} on:volumechange={(e) => { if (!forceMuted) { $videoViewerMuted = e.currentTarget.muted; @@ -69,9 +84,8 @@ muted={forceMuted || $videoViewerMuted} bind:volume={$videoViewerVolume} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })} + src={assetFileUrl} > - - {#if isVideoLoading} diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 129b6c8be7..5f03784c42 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -8,10 +8,20 @@ export let projectionType: string | null | undefined; export let checksum: string; export let loopVideo: boolean; + export let onPreviousAsset: () => void; + export let onNextAsset: () => void; {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte new file mode 100644 index 0000000000..dd54afba01 --- /dev/null +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -0,0 +1,22 @@ + + +
    + + {#if !hideMessage} + {$t('error_loading_image')} + {/if} +
    diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts index 91ea7d3ab1..2525b86160 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts @@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte'; describe('ImageThumbnail component', () => { beforeAll(() => { - Object.defineProperty(HTMLImageElement.prototype, 'decode', { - value: vi.fn(), + Object.defineProperty(HTMLImageElement.prototype, 'complete', { + value: true, }); }); @@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => { const sut = render(ImageThumbnail, { url: 'http://localhost/img.png', altText: 'test', - thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', widthStyle: '250px', }); - const [_, thumbhash] = sut.getAllByRole('img'); - expect(thumbhash.getAttribute('src')).toContain( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated - ); + const thumbhash = sut.getByTestId('thumbhash'); + expect(thumbhash).not.toBeFalsy(); }); }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e391ecb59..662209544a 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,17 +1,18 @@ -{altText} +{#if errored} + +{:else} + {loaded +{/if} {#if hidden}
    @@ -57,18 +83,18 @@
    {/if} -{#if thumbhash && !complete} - {altText} {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 600c30e265..ac67605fc6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,5 @@ - -
    - {#if intersecting} +
    + {#if !loaded && asset.thumbhash} + + {/if} + + {#if display} + +
    { + if (evt.key === 'Enter') { + callClickHandlers(); + } + }} + tabindex={0} + on:click={handleClick} + role="link" + > + {#if mouseOver && !disableMouseOver} + + evt.preventDefault()} + tabindex={0} + > + + {/if}
    {#if !readonly && (mouseOver || selected || selectionCandidate)} @@ -170,37 +285,32 @@ - {#if asset.stackCount && showStackedIcon} + {#if asset.stack && showStackedIcon}
    -

    {asset.stackCount.toLocaleString($locale)}

    +

    {asset.stack.assetCount.toLocaleString($locale)}

    {/if} - {#if asset.resized} - - {:else} -
    - -
    - {/if} + (loaded = true)} + /> {#if asset.type === AssetTypeEnum.Video}
    {/if} - {/if} - - +
    + {/if} +
    diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 5c4196e54b..5cac0b1945 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { AssetStore } from '$lib/stores/assets.store'; + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + export let assetStore: AssetStore | undefined = undefined; export let url: string; export let durationInSeconds = 0; export let enablePlayback = false; @@ -13,6 +17,7 @@ export let playIcon = mdiPlayCircleOutline; export let pauseIcon = mdiPauseCircleOutline; + const componentId = generateId(); let remainingSeconds = durationInSeconds; let loading = true; let error = false; @@ -27,6 +32,43 @@ player.src = ''; } } + const onMouseEnter = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = true; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = true; + } + } + }; + + const onMouseLeave = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = false; + } + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + });
    @@ -37,19 +79,7 @@ {/if} - { - if (playbackOnIconHover) { - enablePlayback = true; - } - }} - on:mouseleave={() => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }} - > + {#if enablePlayback} {#if loading} diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 76f962f107..8af3f75ade 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,7 +1,7 @@ -
    +
    + {label} {#each filters as filter, index} - +
    + onSelect(filter)} + /> + +
    {/each} -
    + diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index bb8377e653..5965928718 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -14,14 +14,19 @@ export let ariaHidden: boolean | undefined = undefined; export let ariaLabel: string | undefined = undefined; export let ariaLabelledby: string | undefined = undefined; + export let strokeWidth: number = 0; + export let strokeColor: string = 'currentColor'; + export let spin = false; import { mdiClose, mdiMagnify } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import type { SearchOptions } from '$lib/utils/dipatch'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; @@ -10,20 +9,20 @@ export let roundedBottom = true; export let showLoadingSpinner: boolean; export let placeholder: string; + export let onSearch: (options: SearchOptions) => void = () => {}; + export let onReset: () => void = () => {}; let inputRef: HTMLElement; - const dispatch = createEventDispatcher<{ search: SearchOptions; reset: void }>(); - const resetSearch = () => { name = ''; - dispatch('reset'); + onReset(); inputRef?.focus(); }; const handleSearch = (event: KeyboardEvent) => { if (event.key === 'Enter') { - dispatch('search', { force: true }); + onSearch({ force: true }); } }; @@ -38,7 +37,7 @@ title={$t('search')} size="16" padding="2" - on:click={() => dispatch('search', { force: true })} + on:click={() => onSearch({ force: true })} />
    diff --git a/web/src/lib/components/elements/slider.svelte b/web/src/lib/components/elements/slider.svelte index 68b085fb91..efe67fda9c 100644 --- a/web/src/lib/components/elements/slider.svelte +++ b/web/src/lib/components/elements/slider.svelte @@ -1,6 +1,4 @@
    @@ -669,8 +718,8 @@ {#if viewMode === ViewMode.SELECT_USERS} handleAddUsers(users)} - on:share={() => (viewMode = ViewMode.LINK_SHARING)} + onSelect={handleAddUsers} + onShare={() => (viewMode = ViewMode.LINK_SHARING)} onClose={() => (viewMode = ViewMode.VIEW)} /> {/if} @@ -683,8 +732,8 @@ (viewMode = ViewMode.VIEW)} {album} - on:remove={({ detail: userId }) => handleRemoveUser(userId)} - on:refreshAlbum={refreshAlbum} + onRemove={(userId) => handleRemoveUser(userId, ViewMode.VIEW_USERS)} + onRefreshAlbum={refreshAlbum} /> {/if} @@ -693,10 +742,15 @@ {album} order={albumOrder} user={$user} - onChangeOrder={(order) => (albumOrder = order)} - on:close={() => (viewMode = ViewMode.VIEW)} - on:toggleEnableActivity={handleToggleEnableActivity} - on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} + onChangeOrder={async (order) => { + albumOrder = order; + await setModeToView(); + }} + onRemove={(userId) => handleRemoveUser(userId, ViewMode.OPTIONS)} + onRefreshAlbum={refreshAlbum} + onClose={() => (viewMode = ViewMode.VIEW)} + onToggleEnabledActivity={handleToggleEnableActivity} + onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} /> {/if} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e3fb4cb28..2ce1309351 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -17,6 +17,7 @@ import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -25,6 +26,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -45,7 +50,7 @@ {/if} - + diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte index 23e7c4aea9..1f71269c11 100644 --- a/web/src/routes/(user)/buy/+page.svelte +++ b/web/src/routes/(user)/buy/+page.svelte @@ -9,7 +9,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { mdiAlertCircleOutline } from '@mdi/js'; import { purchaseStore } from '$lib/stores/purchase.store'; - import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; + import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; export let data: PageData; let showLicenseActivated = false; @@ -30,14 +30,7 @@ {/if} {#if $isPurchased} -
    -
    - -
    -

    {$t('purchase_account_info')}

    -
    + {/if} {#if showLicenseActivated || data.isActivated === true} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 7c6424b5ac..18c9dffdf3 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -10,6 +10,7 @@ import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { onMount } from 'svelte'; import { websocketEvents } from '$lib/stores/websocket'; + import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; export let data: PageData; @@ -19,25 +20,14 @@ OBJECTS = 'smartInfo.objects', } - let MAX_PEOPLE_ITEMS: number; - let MAX_PLACE_ITEMS: number; - let innerWidth: number; - let screenSize: number; const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => { const targetField = items.find((item) => item.fieldName === field); return targetField?.items || []; }; - $: places = getFieldItems(data.items, Field.CITY).slice(0, MAX_PLACE_ITEMS); - $: people = data.response.people.slice(0, MAX_PEOPLE_ITEMS); + $: places = getFieldItems(data.items, Field.CITY); + $: people = data.response.people; $: hasPeople = data.response.total > 0; - $: { - if (innerWidth && screenSize) { - // Set the number of faces according to the screen size and the div size - MAX_PEOPLE_ITEMS = screenSize < 768 ? Math.floor(innerWidth / 96) : Math.floor(innerWidth / 120); - MAX_PLACE_ITEMS = screenSize < 768 ? Math.floor(innerWidth / 150) : Math.floor(innerWidth / 172); - } - } onMount(() => { return websocketEvents.on('on_person_thumbnail', (personId: string) => { @@ -52,8 +42,6 @@ }); - - {#if hasPeople}
    @@ -65,25 +53,14 @@ draggable="false">{$t('view_all')}
    -
    - {#if MAX_PEOPLE_ITEMS} - {#each people as person (person.id)} - - -

    {person.name}

    -
    - {/each} - {/if} -
    + + {#each people.slice(0, itemCount) as person (person.id)} + + +

    {person.name}

    +
    + {/each} +
    {/if} @@ -97,16 +74,14 @@ draggable="false">{$t('view_all')}
    -
    - {#each places as item (item.data.id)} + + {#each places.slice(0, itemCount) as item (item.data.id)} -
    +
    {item.value}
    {/each} -
    +
    {/if} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49af165ac9..13e70c9161 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,6 +19,7 @@ import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -27,6 +28,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + + onDestroy(() => { + assetStore.destroy(); + }); @@ -50,7 +55,7 @@ {/if} - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..2cd3d8c9f3 --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,84 @@ + + + + + +
    +
    {$t('explorer').toUpperCase()}
    +
    + +
    +
    +
    + + + +
    + + + + {#if data.pathAssets && data.pathAssets.length > 0} +
    + +
    + {/if} +
    +
    diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..41800c1a7d --- /dev/null +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,42 @@ +import { QueryParameter } from '$lib/constants'; +import { foldersStore } from '$lib/stores/folders.store'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + await foldersStore.fetchUniquePaths(); + const { uniquePaths } = get(foldersStore); + + let pathAssets = null; + + const path = url.searchParams.get(QueryParameter.PATH); + if (path) { + await foldersStore.fetchAssetsByPath(path); + const { assets } = get(foldersStore); + pathAssets = assets[path] || null; + } + + let tree = buildTree(uniquePaths || []); + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + tree = tree?.[part]; + } + + return { + asset, + path, + currentFolders: Object.keys(tree || {}), + pathAssets, + meta: { + title: $t('folders'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1b5923663b..adbc3cfe69 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -36,7 +36,9 @@ assetViewingStore.showAssetViewer(false); }); - $: $featureFlags.map || handlePromiseError(goto(AppRoute.PHOTOS)); + $: if (!$featureFlags.map) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } const omit = (obj: MapSettings, key: string) => { return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)); }; @@ -111,18 +113,21 @@ {#if $featureFlags.loaded && $featureFlags.map}
    - onViewAssets(event.detail)} /> -
    + +
    + {#if $showAssetViewer} {#await import('../../../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} 1} - on:next={navigateNext} - on:previous={navigatePrevious} - on:close={() => assetViewingStore.showAssetViewer(false)} + onNext={navigateNext} + onPrevious={navigatePrevious} + onClose={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} isShared={false} /> {/await} @@ -132,11 +137,11 @@ {#if showSettingsModal} (showSettingsModal = false)} - on:save={async ({ detail }) => { - const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); + onClose={() => (showSettingsModal = false)} + onSave={async (settings) => { + const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); showSettingsModal = false; - $mapSettings = detail; + $mapSettings = settings; if (shouldUpdate) { mapMarkers = await loadMapMarkers(); diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83e2ba3c1f..2caab9de82 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -23,6 +23,7 @@ onDestroy(() => { assetInteractionStore.clearMultiselect(); + assetStore.destroy(); }); @@ -37,7 +38,7 @@ {:else} - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}>

    {data.partner.name}'s photos @@ -45,5 +46,5 @@ {/if} - + diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f1a2674e24..b6d25c48bf 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -302,9 +302,9 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (showMergeModal = false)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (showMergeModal = false)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} @@ -349,10 +349,10 @@ handleChangeName(person)} - on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-people={() => handleMergePeople(person)} - on:hide-person={() => handleHidePerson(person)} + onChangeName={() => handleChangeName(person)} + onSetBirthDate={() => handleSetBirthDate(person)} + onMergePeople={() => handleMergePeople(person)} + onHidePerson={() => handleHidePerson(person)} /> {:else} @@ -397,8 +397,8 @@ {#if showSetBirthDateModal} (showSetBirthDateModal = false)} - on:updated={(event) => submitBirthDateChange(event.detail)} + onClose={() => (showSetBirthDateModal = false)} + onUpdate={submitBirthDateChange} /> {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02afe7f610..d68367d106 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -52,7 +52,7 @@ mdiEyeOutline, mdiPlus, } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; @@ -76,13 +76,20 @@ isArchived: false, personId: data.person.id, }); + + $: person = data.person; + $: thumbnailData = getPeopleThumbnailUrl(person); + $: if (person) { + handlePromiseError(updateAssetCount()); + handlePromiseError(assetStore.updateOptions({ personId: person.id })); + } + const assetInteractionStore = createAssetInteractionStore(); const { selectedAssets, isMultiSelectState } = assetInteractionStore; let viewMode: ViewMode = ViewMode.VIEW_ASSETS; let isEditingName = false; let previousRoute: string = AppRoute.EXPLORE; - let previousPersonId: string = data.person.id; let people: PersonResponseDto[] = []; let personMerge1: PersonResponseDto; let personMerge2: PersonResponseDto; @@ -91,9 +98,6 @@ let refreshAssetGrid = false; let personName = ''; - $: thumbnailData = getPeopleThumbnailUrl(data.person); - - let name: string = data.person.name; let suggestedPeople: PersonResponseDto[] = []; /** @@ -120,8 +124,8 @@ } return websocketEvents.on('on_person_thumbnail', (personId: string) => { - if (data.person.id === personId) { - thumbnailData = getPeopleThumbnailUrl(data.person, Date.now().toString()); + if (person.id === personId) { + thumbnailData = getPeopleThumbnailUrl(person, Date.now().toString()); } }); }); @@ -141,7 +145,7 @@ const updateAssetCount = async () => { try { - const { assets } = await getPersonStatistics({ id: data.person.id }); + const { assets } = await getPersonStatistics({ id: person.id }); numberOfAssets = assets; } catch (error) { handleError(error, "Can't update the asset count"); @@ -150,19 +154,9 @@ afterNavigate(({ from }) => { // Prevent setting previousRoute to the current page. - if (from && from.route.id !== $page.route.id) { + if (from?.url && from.route.id !== $page.route.id) { previousRoute = from.url.href; } - if (previousPersonId !== data.person.id) { - handlePromiseError(updateAssetCount()); - assetStore = new AssetStore({ - isArchived: false, - personId: data.person.id, - }); - previousPersonId = data.person.id; - name = data.person.name; - refreshAssetGrid = !refreshAssetGrid; - } }); const handleUnmerge = () => { @@ -178,8 +172,8 @@ const toggleHidePerson = async () => { try { await updatePerson({ - id: data.person.id, - personUpdateDto: { isHidden: !data.person.isHidden }, + id: person.id, + personUpdateDto: { isHidden: !person.isHidden }, }); notificationController.show({ @@ -207,7 +201,7 @@ return; } try { - await updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } }); + person = await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } }); notificationController.show({ message: $t('feature_photo_updated'), type: NotificationType.Info }); } catch (error) { handleError(error, $t('errors.unable_to_set_feature_photo')); @@ -232,7 +226,7 @@ type: NotificationType.Info, }); people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); - if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) { + if (personToBeMergedIn.name != personName && person.id === personToBeMergedIn.id) { await updateAssetCount(); refreshAssetGrid = !refreshAssetGrid; return; @@ -243,22 +237,22 @@ } }; - const handleSuggestPeople = (person: PersonResponseDto) => { + const handleSuggestPeople = (person2: PersonResponseDto) => { isEditingName = false; potentialMergePeople = []; personName = person.name; - personMerge1 = data.person; - personMerge2 = person; + personMerge1 = person; + personMerge2 = person2; viewMode = ViewMode.SUGGEST_MERGE; }; const changeName = async () => { viewMode = ViewMode.VIEW_ASSETS; - data.person.name = personName; + person.name = personName; try { isEditingName = false; - await updatePerson({ id: data.person.id, personUpdateDto: { name: personName } }); + person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } }); notificationController.show({ message: $t('change_name_successfully'), @@ -282,7 +276,7 @@ potentialMergePeople = []; personName = name; - if (data.person.name === personName) { + if (person.name === personName) { return; } if (name === '') { @@ -293,12 +287,11 @@ const result = await searchPerson({ name: personName, withHidden: true }); const existingPerson = result.find( - (person: PersonResponseDto) => - person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name, + ({ name, id }: PersonResponseDto) => name.toLowerCase() === personName.toLowerCase() && id !== person.id && name, ); if (existingPerson) { personMerge2 = existingPerson; - personMerge1 = data.person; + personMerge1 = person; potentialMergePeople = result .filter( (person: PersonResponseDto) => @@ -317,10 +310,10 @@ const handleSetBirthDate = async (birthDate: string) => { try { viewMode = ViewMode.VIEW_ASSETS; - data.person.birthDate = birthDate; + person.birthDate = birthDate; const updatedPerson = await updatePerson({ - id: data.person.id, + id: person.id, personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null }, }); @@ -344,14 +337,18 @@ await goto($page.url); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if viewMode === ViewMode.UNASSIGN_ASSETS} a.id)} - personAssets={data.person} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:confirm={handleUnmerge} + personAssets={person} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} /> {/if} @@ -360,22 +357,22 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} {#if viewMode === ViewMode.BIRTH_DATE} (viewMode = ViewMode.VIEW_ASSETS)} - on:updated={(event) => handleSetBirthDate(event.detail)} + birthDate={person.birthDate ?? ''} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onUpdate={handleSetBirthDate} /> {/if} {#if viewMode === ViewMode.MERGE_PEOPLE} - handleMerge(detail)} /> + {/if}

    @@ -389,7 +386,7 @@ assetStore.triggerUpdate()} /> - + {:else} {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - goto(previousRoute)}> + goto(previousRoute)}> (viewMode = ViewMode.SELECT_PERSON)} /> toggleHidePerson()} /> (viewMode = ViewMode.VIEW_ASSETS)}> + (viewMode = ViewMode.VIEW_ASSETS)}> {$t('select_featured_photo')} {/if} @@ -440,14 +437,15 @@
    - {#key refreshAssetGrid} + {#key person.id} handleSelectFeaturePhoto(asset)} - on:escape={handleEscape} + onSelect={handleSelectFeaturePhoto} + onEscape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} @@ -462,11 +460,11 @@
    {#if isEditingName} handleNameChange(event.detail)} + onChange={handleNameChange} {thumbnailData} /> {:else} @@ -481,24 +479,17 @@ circle shadow url={thumbnailData} - altText={data.person.name} + altText={person.name} widthStyle="3.375rem" heightStyle="3.375rem" />
    - {#if data.person.name} -

    {data.person.name}

    -

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

    - {:else} -

    {$t('add_a_name')}

    -

    - {$t('find_them_fast')} -

    - {/if} +

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

    +

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

    diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 85a497fa99..ba8ee13cc9 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -2,27 +2,32 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; - import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; - import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; + import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import MemoryLane from '$lib/components/photos-page/memory-lane.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; - import { AssetStore } from '$lib/stores/assets.store'; - import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { AssetStore } from '$lib/stores/assets.store'; import { preferences, user } from '$lib/stores/user.store'; + import type { OnLink, OnUnlink } from '$lib/utils/actions'; + import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { AssetTypeEnum } from '@immich/sdk'; + import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; @@ -31,12 +36,21 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; let isAllFavorite: boolean; + let isAllOwned: boolean; let isAssetStackSelected: boolean; + let isLinkActionAvailable: boolean; $: { const selection = [...$selectedAssets]; + isAllOwned = selection.every((asset) => asset.ownerId === $user.id); isAllFavorite = selection.every((asset) => asset.isFavorite); isAssetStackSelected = selection.length === 1 && !!selection[0].stack; + const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; + const isLivePhotoCandidate = + selection.length === 2 && + selection.some((asset) => asset.type === AssetTypeEnum.Image) && + selection.some((asset) => asset.type === AssetTypeEnum.Image); + isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); } const handleEscape = () => { @@ -48,6 +62,20 @@ return; } }; + + const handleLink: OnLink = ({ still, motion }) => { + assetStore.removeAssets([motion.id]); + assetStore.updateAssets([still]); + }; + + const handleUnlink: OnUnlink = ({ still, motion }) => { + assetStore.addAssets([motion]); + assetStore.updateAssets([still]); + }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -72,9 +100,20 @@ onUnstack={(assets) => assetStore.addAssets(assets)} /> {/if} + {#if isLinkActionAvailable} + + {/if} assetStore.removeAssets(assetIds)} /> + {#if $preferences.tags.enabled} + + {/if} assetStore.removeAssets(assetIds)} />
    @@ -84,10 +123,11 @@ {#if $preferences.memories.enabled} 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 98e00b6970..4605a2207e 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 @@ -40,6 +40,8 @@ import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation'; import { t } from 'svelte-i18n'; + import { afterUpdate, tick } from 'svelte'; + import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; const MAX_ASSET_COUNT = 5000; let { isViewing: showAssetViewer } = assetViewingStore; @@ -54,6 +56,8 @@ let searchResultAlbums: AlbumResponseDto[] = []; let searchResultAssets: AssetResponseDto[] = []; let isLoading = true; + let scrollY = 0; + let scrollYHistory = 0; const onEscape = () => { if ($showAssetViewer) { @@ -70,6 +74,13 @@ $preventRaceConditionSearchBar = false; }; + // save and restore scroll position + afterUpdate(() => { + if (scrollY) { + scrollYHistory = scrollY; + } + }); + afterNavigate(({ from }) => { // Prevent setting previousRoute to the current page. if (from?.url && from.route.id !== $page.route.id) { @@ -84,6 +95,14 @@ if (isAlbumsRoute(route)) { previousRoute = AppRoute.EXPLORE; } + + tick() + .then(() => { + window.scrollTo(0, scrollYHistory); + }) + .catch(() => { + // do nothing + }); }); let selectedAssets: Set = new Set(); @@ -198,12 +217,17 @@ const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); + const onAddToAlbum = (assetIds: string[]) => { + const assetIdSet = new Set(assetIds); + searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); + }; + function getObjectKeys(obj: T): (keyof T)[] { return Object.keys(obj) as (keyof T)[]; } - +
    {#if isMultiSelectionMode} @@ -212,8 +236,8 @@ - - + + @@ -223,12 +247,14 @@ +
    +
    {:else}
    - goto(previousRoute)} backIcon={mdiArrowLeft}> + goto(previousRoute)} backIcon={mdiArrowLeft}>
    @@ -259,6 +285,8 @@ {#await getPersonName(value) then personName} {personName} {/await} + {:else if value === null || value === ''} + {$t('unknown')} {:else} {value} {/if} @@ -289,7 +317,7 @@ diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5ebb0e294c..f4fac282ba 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,8 +11,13 @@ import type { PageData } from './$types'; import { setSharedLink } from '$lib/utils'; import { t } from 'svelte-i18n'; + import { navigate } from '$lib/utils/navigation'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { tick } from 'svelte'; export let data: PageData; + + let { gridScrollTarget } = assetViewingStore; let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; let { title, description } = meta; let isOwned = $user ? $user.id === sharedLink?.userId : false; @@ -29,6 +34,11 @@ description = sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } }); + await tick(); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { forceNavigate: true, replaceState: true }, + ); } catch (error) { handleError(error, $t('errors.unable_to_get_shared_link')); } diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 09d3d2d400..67e80f4703 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -1,6 +1,5 @@ - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}> {$t('shared_links')} -
    -
    +
    +

    {$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)} - on:edit={() => (editSharedLink = link)} - on:copy={() => handleCopyLink(link.key)} - /> + handleDeleteLink(link.id)} onEdit={() => (editSharedLink = link)} /> {/each}
    {/if} 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 new file mode 100644 index 0000000000..ce91abb451 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,230 @@ + + + + + +
    +
    {$t('explorer').toUpperCase()}
    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    + + {#if pathSegments.length > 0 && tag} + +
    + + +
    +
    + +
    + + +
    +
    + {/if} +
    + + + +
    + {#if tag} + + + + {:else} + + {/if} +
    +
    + +{#if isNewOpen} + +
    +

    + {$t('create_tag_description')} +

    +
    + +
    +
    + +
    +
    + + + + +
    +{/if} + +{#if isEditOpen} + +
    +
    + +
    +
    + + + + +
    +{/if} diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..23846e57c4 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,32 @@ +import { QueryParameter } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { getAllTags } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + const path = url.searchParams.get(QueryParameter.PATH); + const tags = await getAllTags(); + const tree = buildTree(tags.map((tag) => tag.value)); + let currentTree = tree; + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + currentTree = currentTree?.[part]; + } + + return { + tags, + asset, + path, + children: Object.keys(currentTree || {}), + meta: { + title: $t('tags'), + }, + }; +}) satisfies PageLoad; 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 0708ec5de9..862d9382a4 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 @@ -25,12 +25,16 @@ import { handlePromiseError } from '$lib/utils'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; - $featureFlags.trash || handlePromiseError(goto(AppRoute.PHOTOS)); + if (!$featureFlags.trash) { + handlePromiseError(goto(AppRoute.PHOTOS)); + } - const assetStore = new AssetStore({ isTrashed: true }); + const options = { isTrashed: true }; + const assetStore = new AssetStore(options); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; @@ -44,16 +48,15 @@ } try { - await emptyTrash(); - - const deletedAssetIds = assetStore.assets.map((a) => a.id); - const numberOfAssets = deletedAssetIds.length; - assetStore.removeAssets(deletedAssetIds); + const { count } = await emptyTrash(); notificationController.show({ - message: $t('assets_permanently_deleted_count', { values: { count: numberOfAssets } }), + message: $t('assets_permanently_deleted_count', { values: { count } }), type: NotificationType.Info, }); + + // reset asset grid (TODO fix in asset store that it should reset when it is empty) + await assetStore.updateOptions(options); } catch (error) { handleError(error, $t('errors.unable_to_empty_trash')); } @@ -68,20 +71,22 @@ return; } try { - await restoreTrash(); - - const restoredAssetIds = assetStore.assets.map((a) => a.id); - const numberOfAssets = restoredAssetIds.length; - assetStore.removeAssets(restoredAssetIds); - + const { count } = await restoreTrash(); notificationController.show({ - message: $t('assets_restored_count', { values: { count: numberOfAssets } }), + message: $t('assets_restored_count', { values: { count } }), type: NotificationType.Info, }); + + // reset asset grid (TODO fix in asset store that it should reset when it is empty) + await assetStore.updateOptions(options); } catch (error) { handleError(error, $t('errors.unable_to_restore_trash')); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -109,7 +114,7 @@
    - +

    {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}

    diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index ea302454b2..4ed46b580f 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -27,5 +27,5 @@ {#if isShowKeyboardShortcut} - (isShowKeyboardShortcut = false)} /> + (isShowKeyboardShortcut = false)} /> {/if} 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 3a9bfbea7f..e1029b7ccb 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 @@ -6,6 +6,7 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte'; + import type { AssetResponseDto } from '@immich/sdk'; import { featureFlags } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { deleteAssets, updateAssets } from '@immich/sdk'; @@ -13,10 +14,11 @@ import type { PageData } from './$types'; import { suggestDuplicateByFileSize } from '$lib/utils'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; + import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js'; + import { stackAssets } from '$lib/utils/asset-utils'; import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { mdiKeyboard } from '@mdi/js'; - import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; @@ -40,6 +42,7 @@ { key: ['s'], action: $t('view') }, { key: ['d'], action: $t('unselect_all_duplicates') }, { key: ['⇧', 'c'], action: $t('resolve_duplicates') }, + { key: ['⇧', 's'], action: $t('stack_duplicates') }, ], }; @@ -88,6 +91,13 @@ ); }; + const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => { + await stackAssets(assets, false); + const duplicateAssetIds = assets.map((asset) => asset.id); + await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } }); + data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId); + }; + const handleDeduplicateAll = async () => { const idsToKeep = data.duplicates .map((group) => suggestDuplicateByFileSize(group.assets)) @@ -174,6 +184,7 @@ assets={data.duplicates[0].assets} onResolve={(duplicateAssetIds, trashIds) => handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)} + onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)} /> {/key} {:else} @@ -185,5 +196,5 @@ {#if isShowKeyboardShortcut} - (isShowKeyboardShortcut = false)} /> + (isShowKeyboardShortcut = false)} /> {/if} diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index e82605d83e..23e8fd3ff1 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,106 +1,6 @@ -
    -
    -
    - - - -
    -
    - -
    -
    -
    -
    -
    -

    - 🚨 {$t('error_title')} -

    -
    - handleCopy()} - /> -
    -
    - -
    - -
    -
    -

    {$page.error?.message} ({$page.error?.code})

    - {#if $page.error?.stack} - -
    {$page.error?.stack || 'No stack'}
    - {/if} -
    -
    - -
    - - -
    -
    -
    -
    -
    + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d086129d7f..8f8bd033eb 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -10,16 +10,19 @@ import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store'; - import { loadConfig, serverConfig } from '$lib/stores/server-config.store'; + + import { serverConfig } from '$lib/stores/server-config.store'; + import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; - import { setKey } from '$lib/utils'; - import { handleError } from '$lib/utils/handle-error'; + import { copyToClipboard, setKey } from '$lib/utils'; import { onDestroy, onMount } from 'svelte'; import '../app.css'; import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation'; import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte'; import { t } from 'svelte-i18n'; + import Error from '$lib/components/error.svelte'; + import { shortcut } from '$lib/actions/shortcut'; let showNavigationLoadingBar = false; $: changeTheme($colorTheme); @@ -32,8 +35,7 @@ const changeTheme = (theme: ThemeSetting) => { if (theme.system) { - theme.value = - window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; + theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT; } if (theme.value === Theme.LIGHT) { @@ -49,7 +51,13 @@ } }; + const getMyImmichLink = () => { + return new URL($page.url.pathname + $page.url.search, 'https://my.immich.app'); + }; + onMount(() => { + const element = document.querySelector('#stencil'); + element?.remove(); // if the browser theme changes, changes the Immich theme too window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme); }); @@ -63,6 +71,8 @@ } beforeNavigate(({ from, to }) => { + setKey(isSharedLinkRoute(to?.route.id) ? to?.params?.key : undefined); + if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) { return; } @@ -72,19 +82,11 @@ afterNavigate(() => { showNavigationLoadingBar = false; }); - - onMount(async () => { - try { - await loadConfig(); - } catch (error) { - handleError(error, $t('errors.unable_to_connect_to_server')); - } - }); {$page.data.meta?.title || 'Web'} - Immich - + @@ -123,7 +125,18 @@ - + copyToClipboard(getMyImmichLink().toString()), + }} +/> + +{#if $page.data.error} + +{:else} + +{/if} {#if showNavigationLoadingBar} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index e8f665e0e4..b5edece09e 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,19 +1,19 @@ -import { initApp } from '$lib/utils'; -import { defaults } from '@immich/sdk'; +import { init } from '$lib/utils/server'; import type { LayoutLoad } from './$types'; export const ssr = false; export const csr = true; export const load = (async ({ fetch }) => { - // set event.fetch on the fetch-client used by @immich/sdk - // https://kit.svelte.dev/docs/load#making-fetch-requests - // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options - defaults.fetch = fetch; - - await initApp(); + let error; + try { + await init(fetch); + } catch (initError) { + error = initError; + } return { + error, meta: { title: 'Immich', }, diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index f9897336af..0f3a7377d2 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,23 +1,35 @@ import { AppRoute } from '$lib/constants'; +import { serverConfig } from '$lib/stores/server-config.store'; import { getFormatter } from '$lib/utils/i18n'; -import { getServerConfig } from '@immich/sdk'; +import { init } from '$lib/utils/server'; + import { redirect } from '@sveltejs/kit'; +import { get } from 'svelte/store'; import { loadUser } from '../lib/utils/auth'; import type { PageLoad } from './$types'; export const ssr = false; export const csr = true; -export const load = (async () => { - const authenticated = await loadUser(); - if (authenticated) { - redirect(302, AppRoute.PHOTOS); - } +export const load = (async ({ fetch }) => { + try { + await init(fetch); + const authenticated = await loadUser(); + if (authenticated) { + redirect(302, AppRoute.PHOTOS); + } - const { isInitialized } = await getServerConfig(); - if (isInitialized) { - // Redirect to login page if there exists an admin account (i.e. server is initialized) - redirect(302, AppRoute.AUTH_LOGIN); + const { isInitialized } = get(serverConfig); + if (isInitialized) { + // Redirect to login page if there exists an admin account (i.e. server is initialized) + redirect(302, AppRoute.AUTH_LOGIN); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (redirectError: any) { + if (redirectError?.status === 302) { + throw redirectError; + } } const $t = await getFormatter(); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index dcd6630a01..16c2541e61 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -3,10 +3,17 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import { asyncTimeout } from '$lib/utils'; - import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; - import { mdiCog } from '@mdi/js'; + import { handleError } from '$lib/utils/handle-error'; + import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk'; + import { mdiCog, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -16,6 +23,8 @@ let jobs: AllJobStatusResponseDto; let running = true; + let isOpen = false; + let selectedJob: ComboBoxOption | undefined = undefined; onMount(async () => { while (running) { @@ -27,10 +36,38 @@ onDestroy(() => { running = false; }); + + const options = [ + { 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 }, + ].map(({ value, title }) => ({ id: value, label: title, value })); + + const handleCancel = () => (isOpen = false); + + const handleCreate = async () => { + if (!selectedJob) { + return; + } + + try { + await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } }); + notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info }); + handleCancel(); + } catch (error) { + handleError(error, $t('errors.unable_to_submit_job')); + } + };
    + (isOpen = true)}> +
    + + {$t('admin.create_job')} +
    +
    @@ -46,3 +83,24 @@
    + +{#if isOpen} + +
    +
    + +
    +
    +
    +{/if} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 64b104624b..daa92491eb 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -20,7 +20,6 @@ getAllLibraries, getLibraryStatistics, getUserAdmin, - removeOfflineFiles, scanLibrary, updateLibrary, type LibraryResponseDto, @@ -122,7 +121,7 @@ const handleScanAll = async () => { try { for (const library of libraries) { - await scanLibrary({ id: library.id, scanLibraryDto: {} }); + await scanLibrary({ id: library.id }); } notificationController.show({ message: $t('admin.refreshing_all_libraries'), @@ -135,9 +134,9 @@ const handleScan = async (libraryId: string) => { try { - await scanLibrary({ id: libraryId, scanLibraryDto: {} }); + await scanLibrary({ id: libraryId }); notificationController.show({ - message: $t('admin.scanning_library_for_new_files'), + message: $t('admin.scanning_library'), type: NotificationType.Info, }); } catch (error) { @@ -145,42 +144,6 @@ } }; - const handleScanChanges = async (libraryId: string) => { - try { - await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } }); - notificationController.show({ - message: $t('admin.scanning_library_for_changed_files'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_scan_library')); - } - }; - - const handleForceScan = async (libraryId: string) => { - try { - await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } }); - notificationController.show({ - message: $t('admin.forcing_refresh_library_files'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_scan_library')); - } - }; - - const handleRemoveOffline = async (libraryId: string) => { - try { - await removeOfflineFiles({ id: libraryId }); - notificationController.show({ - message: $t('admin.removing_offline_files'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_remove_offline_files')); - } - }; - const onRenameClicked = (index: number) => { closeAll(); renameLibrary = index; @@ -193,7 +156,7 @@ updateLibraryIndex = index; }; - const onScanNewLibraryClicked = async (library: LibraryResponseDto) => { + const onScanClicked = async (library: LibraryResponseDto) => { closeAll(); if (library) { @@ -207,27 +170,6 @@ updateLibraryIndex = index; }; - const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => { - closeAll(); - if (library) { - await handleScanChanges(library.id); - } - }; - - const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => { - closeAll(); - if (library) { - await handleForceScan(library.id); - } - }; - - const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => { - closeAll(); - if (library) { - await handleRemoveOffline(library.id); - } - }; - const handleDelete = async (library: LibraryResponseDto, index: number) => { closeAll(); @@ -267,10 +209,7 @@ {#if toCreateLibrary} - handleCreate(detail.ownerId)} - on:cancel={() => (toCreateLibrary = false)} - /> + (toCreateLibrary = false)} /> {/if} @@ -329,17 +268,21 @@ {:else}{owner[index].name}{/if} - - {#if totalCount[index] == undefined} - + + {#if totalCount[index] == undefined} - - {:else} - + {:else} {totalCount[index].toLocaleString($locale)} - - {diskUsage[index]} {diskUsageUnit[index]} - {/if} + {/if} + + + {#if diskUsage[index] == undefined} + + {:else} + {diskUsage[index]} + {diskUsageUnit[index]} + {/if} + + onScanClicked(library)} text={$t('scan_library')} /> +
    onRenameClicked(index)} text={$t('rename')} /> onEditImportPathClicked(index)} text={$t('edit_import_paths')} /> onScanSettingClicked(index)} text={$t('scan_settings')} />
    - onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} /> onScanAllLibraryFilesClicked(library)} - text={$t('scan_all_library_files')} - subtitle={$t('only_refreshes_modified_files')} - /> - onForceScanAllLibraryFilesClicked(library)} - text={$t('force_re-scan_library_files')} - subtitle={$t('refreshes_every_file')} - /> -
    - onRemoveOfflineFilesClicked(library)} - text={$t('remove_offline_files')} - /> - handleDelete(library, index)} activeColor="bg-red-200" textColor="text-red-600" - onClick={() => handleDelete(library, index)} + text={$t('delete_library')} />
    {#if renameLibrary === index}
    - handleUpdate(detail)} - on:cancel={() => (renameLibrary = null)} - /> + (renameLibrary = null)} />
    {/if} {#if editImportPaths === index}
    - handleUpdate(detail)} - on:cancel={() => (editImportPaths = null)} - /> + (editImportPaths = null)} />
    {/if} {#if editScanSettings === index}
    handleUpdate(library)} - on:cancel={() => (editScanSettings = null)} + onSubmit={handleUpdate} + onCancel={() => (editScanSettings = null)} />
    {/if} diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index eff9336121..f724e2d145 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -1,9 +1,11 @@ @@ -175,6 +237,9 @@
    + copyToClipboard(JSON.stringify(config, null, 2))}>
    @@ -187,20 +252,25 @@ {$t('export_as_json')}
    - inputElement?.click()}> -
    - - {$t('import_from_json')} -
    -
    + {#if !$featureFlags.configFile} + inputElement?.click()}> +
    + + {$t('import_from_json')} +
    +
    + {/if}
    -
    +
    +
    + +
    - {#each settings as { component: Component, title, subtitle, key }} - + {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} + handleSave(config)} onReset={(options) => handleReset(options)} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 6028a7dae8..80c0169176 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -1,6 +1,5 @@